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
## dev [v2.2.1 or v2.3.0]
## dev [v2.2.3, v2.3.0, or v3.0.0]
## v2.2.2
#### What's New
- New spanish translations and metadata [courtesy of n-berenice]
#### What's Improved
- Rounded images are more nuanced
- Shuffle and Repeat mode buttons now have more contrast when they are turned on
#### What's Fixed
- Fixed crash on certain devices running Android 10 and lower when a differing theme
from the system theme was used [#80]
- Fixed music loading failure that would occur when certain paths were parsed [#84]
- Fixed incorrect track numbers when the tag was formatted as NN/TT [#88]
- Fixed years deliberately set as "0" showing up as "No Date"
- Fixed headset management unexpectedly starting audio when the app initially opens
- Fixed crash that would occur during a playback restore with specific queue states [#89]
- Partially fixed buggy behavior when multiple queue items were dragged in quick
succession
#### What's Changed
- All cover art is now cropped to a 1:1 aspect ratio
- Headset focus has been replaced with headset autoplay. It can no longer be disabled.
#### Dev/Meta
- Enabled elevation drop shadows below Android P for consistency
- Switches now have a disabled state
- Reworked dynamic color usage
- Reworked logging
- Upgrade ExoPlayer to v2.17.0 [Eliminates custom fork]
## v2.2.1
#### What's Improved
- Updated chinese translations [courtesy of cccClyde]
- Use proper material you top app bars
- Use body typography in correct places
- Expose file opening functionality better
#### What's Fixed
- Fixed issue where playback would start unexpectedly when opening the app
#### What's Changed
- Disabled audio focus customization on Android 12 [#75]
## v2.2.0
#### What's New:
- Added arabic translations [courtesy of hasanpasha]
- Better russian translations [courtesy of lisiczka43]
- Added Arabic translations [Courtesy of hasanpasha]
- Improved Russian translations [Courtesy of lisiczka43]
- Added option to reload the music library
#### What's Improved:
@ -18,9 +61,10 @@ artist they are grouped up in
#### What's Fixed:
- Fixed crash on some devices configured to use French or Czech translations
- Malformed indicies should now be corrected when the playback state is restored
- Malformed indices should now be corrected when the playback state is restored
- Fixed issue where track numbers would not be shown in the native language's numeric format
- Fixed issue where the preference view would apply the M3 switches inconsistently
- Fixed issue where the now playing indicator on the playback screen would use an internal name
#### Dev/Meta:
- Removed 1.4.X compat
@ -178,7 +222,7 @@ to when using gesture navigation
- Fixed issue where the scroll thumb would briefly display on the Songs UI
- Fixed issue where fast scrolling could be triggered outside the bounds of the indicators
- Fixed issue where the wrong playing item would be highlighted if the names were identical
- Fixed a crash when the thumb was moved above the fast scroller [Backported to 1.3.3, included in this release officially]
- Fixed a crash when the thumb was moved above the fast scroller [Back-ported to 1.3.3, included in this release officially]
#### Dev/Meta
- Migrated fully to material design

View file

@ -3,14 +3,14 @@
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<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 href="https://www.gnu.org/licenses/gpl-3.0">
<img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg">
</a>
<img alt="Minimum SDK" src="https://img.shields.io/badge/API-21%2B-32B5ED">
</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">
<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>

View file

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

View file

@ -51,9 +51,12 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="audio/*" />
</intent-filter>
</activity>
@ -66,7 +69,7 @@
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.
-->
<receiver

View file

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

View file

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

View file

@ -36,11 +36,13 @@ import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* A wrapper around the home fragment that shows the playback fragment and controls
* the more high-level navigation features.
* @author OxygenCobalt
* TODO: Add a new view with a stack trace whenever the music loading process fails.
*/
class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
@ -78,10 +80,6 @@ class MainFragment : Fragment() {
// but for some insane reason google decided to cripple the window APIs one could use
// to limit it's size. So, we just have our own special layout that is shown whenever
// the screen is too small because of course we have to.
// Another fun fact: smallestScreenWidthDp is completely bugged and uses the total
// screen size, even when the window is smaller. This basically borks split screen
// even more than it already does. Fun!
if (requireActivity().isInMultiWindowMode) {
val config = resources.configuration
@ -110,7 +108,7 @@ class MainFragment : Fragment() {
// Error, show the error to the user
is MusicStore.Response.Err -> {
logD("Received Error")
logW("Received Error")
val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
@ -142,7 +140,7 @@ class MainFragment : Fragment() {
}
}
logD("Fragment Created.")
logD("Fragment Created")
return binding.root
}

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."
* This can be nominally used to gleam some attributes about a given color scheme, but this
* is not recommended. Attributes are the better option in nearly all cases.
*
* @property name The name of this accent
* @property theme The theme resource for this accent
* @property blackTheme The black theme resource for this accent
@ -111,36 +114,4 @@ data class Accent(val index: Int) {
val theme: Int get() = ACCENT_THEMES[index]
val blackTheme: Int get() = ACCENT_BLACK_THEMES[index]
val primary: Int get() = ACCENT_PRIMARY_COLORS[index]
companion object {
@Volatile
private var CURRENT: Accent? = null
/**
* Get the current accent.
* @return The current accent
* @throws IllegalStateException When the accent has not been set.
*/
fun get(): Accent {
val cur = CURRENT
if (cur != null) {
return cur
}
error("Accent must be set before retrieving it.")
}
/**
* Set the current accent.
* @return The new accent
*/
fun set(accent: Accent): Accent {
synchronized(this) {
CURRENT = accent
}
return accent
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,6 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager
// --- BINDING ADAPTERS ---
@ -65,24 +64,9 @@ fun ImageView.bindGenreImage(genre: Genre?) = load(genre, R.drawable.ic_genre)
fun <T : Music> ImageView.load(music: T?, @DrawableRes error: Int) {
dispose()
// We don't round album covers by default as it desecrates album artwork, but we do provide
// an option if one wants it.
// As for why we use clipToOutline instead of coil's RoundedCornersTransformation, the transform
// uses the dimensions of the image to create the corners, which results in inconsistent corners
// across loaded cover art.
val settingsManager = SettingsManager.getInstance()
if (settingsManager.roundCovers && background == null) {
setBackgroundResource(R.drawable.ui_rounded_cutout)
clipToOutline = true
} else if (!settingsManager.roundCovers && background != null) {
background = null
clipToOutline = false
}
load(music) {
error(error)
transformations(SquareFrameTransform.INSTANCE)
}
}
@ -102,6 +86,7 @@ fun loadBitmap(
ImageRequest.Builder(context)
.data(song.album)
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform())
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,7 +55,6 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
writableDatabase.transaction {
delete(TABLE_NAME, null, null)
logD("Deleted paths db")
for (path in paths) {
@ -66,6 +65,8 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
}
)
}
logD("Successfully wrote ${paths.size} paths to db")
}
}
@ -76,17 +77,20 @@ class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
assertBackgroundThread()
val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor ->
while (cursor.moveToNext()) {
paths.add(cursor.getString(0))
}
}
logD("Successfully read ${paths.size} paths from db")
return paths
}
companion object {
// Blacklist is still used here for compatibility reasons, please don't get
// your pants in a twist about it.
const val DB_VERSION = 1
const val DB_NAME = "auxio_blacklist_database.db"

View file

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

View file

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

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 com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.util.logD
/**
* A tag configuration strategy that automatically adapts the tab layout to the screen size.
@ -20,15 +21,22 @@ class AdaptiveTabStrategy(
val tabMode = homeModel.tabs[position]
when {
width < 370 ->
width < 370 -> {
logD("Using icon-only configuration")
tab.setIcon(tabMode.icon)
.setContentDescription(tabMode.string)
}
width < 640 -> tab.setText(tabMode.string)
width < 640 -> {
logD("Using text-only configuration")
tab.setText(tabMode.string)
}
else ->
else -> {
logD("Using icon-and-text configuration")
tab.setIcon(tabMode.icon)
.setText(tabMode.string)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -25,6 +25,8 @@ import androidx.recyclerview.widget.RecyclerView
/**
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple.
* TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single
* class.
*/
class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() {
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) {}
// We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled(): Boolean = false
/**
* Add the tab adapter to this callback.
* Done because there's a circular dependency between the two objects

View file

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

View file

@ -4,11 +4,12 @@ import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.logE
import java.lang.Exception
import org.oxycblt.auxio.util.logD
/**
* This class acts as the base for most the black magic required to get a remotely sensible music
@ -26,7 +27,7 @@ import java.lang.Exception
* have to query for each genre, query all the songs in each genre, and then iterate through those
* songs to link every song with their genre. This is not documented anywhere, and the
* O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's
* loading times. At no point have the devs considered that this column is absolutely insane, and
* loading times. At no point have the devs considered that this system is absolutely insane, and
* instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their
* own Google Play Music, and of course every Google Play Music user knew how great that turned
* out!
@ -34,7 +35,7 @@ import java.lang.Exception
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files?
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see
* that their metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
* that the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
* DATE tag. Once again, this is because internally android uses an ancient in-house metadata
* parser to get everything indexed, and so far they have not bothered to modernize this parser
* or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has
@ -45,7 +46,7 @@ import java.lang.Exception
* so that songs don't end up fragmented across artists. Pretty much every OEM has added some
* extension or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH)
* crippling the normal tables so that you're railroaded into their music app. The way I do
* blacklisting relies on a deprecated method, and the supposedly "modern" method is SLOWER and
* blacklisting relies on a semi-deprecated method, and the supposedly "modern" method is SLOWER and
* causes even more problems since I have to manage databases across version boundaries. Sometimes
* music will have a deformed clone that I can't filter out, sometimes Genres will just break for
* no reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to
@ -66,13 +67,12 @@ import java.lang.Exception
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
* to your AlgoPop StreamMix instead.
* to your AlgoPop StreamMix.
*
* I wish I was born in the neolithic.
*
* @author OxygenCobalt
*/
@Suppress("InlinedApi")
class MusicLoader {
data class Library(
val genres: List<Genre>,
@ -89,13 +89,18 @@ class MusicLoader {
val artists = buildArtists(context, albums)
val genres = readGenres(context, songs)
// Sanity check: Ensure that all songs are well-formed.
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
try {
song.album.artist
} catch (e: Exception) {
logE("Found malformed song: ${song.name}")
throw e
if (song.internalIsMissingAlbum ||
song.internalIsMissingArtist ||
song.internalIsMissingGenre
) {
throw IllegalStateException(
"Found malformed song: ${song.name} [" +
"album: ${!song.internalIsMissingAlbum} " +
"artist: ${!song.internalIsMissingArtist} " +
"genre: ${!song.internalIsMissingGenre}]"
)
}
}
@ -118,56 +123,76 @@ class MusicLoader {
// DATA was deprecated on Android 10, but is set to be un-deprecated in Android 12L.
// The only reason we'd want to change this is to add external partitions support, but
// that's less efficient and there's no demand for that right now.
// TODO: Determine if grokking the actual DATA value outside of SQL is more or less
// efficient than the current system
for (path in paths) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
// TODO: Move all references to contentResolver into a single variable so we can
// avoid accidentally removing the applicationContext fix
context.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.TRACK,
MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST,
MediaStore.Audio.AudioColumns.ALBUM_ARTIST,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.TRACK,
MediaStore.Audio.AudioColumns.DURATION,
AUDIO_COLUMN_ALBUM_ARTIST
),
selector, args.toTypedArray(), null
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val title = cursor.getString(titleIndex)
val fileName = cursor.getString(fileIndex)
val title = cursor.getString(titleIndex) ?: fileName
// The TRACK field is for some reason formatted as DTTT, where D is the disk
// and T is the track. This is dumb and insane and forces me to mangle track
// numbers above 1000 but there is nothing we can do that won't break the app
// below API 30.
// TODO: Disk number support?
val track = cursor.getIntOrNull(trackIndex)?.mod(1000)
val duration = cursor.getLong(durationIndex)
val year = cursor.getIntOrNull(yearIndex)
val album = cursor.getString(albumIndex)
val albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on.
val artist = cursor.getString(artistIndex).let {
if (it != MediaStore.UNKNOWN_STRING) it else null
val artist = cursor.getStringOrNull(artistIndex)?.run {
if (this == MediaStore.UNKNOWN_STRING) {
null
} else {
this
}
}
val albumArtist = cursor.getStringOrNull(albumArtistIndex)
val year = cursor.getInt(yearIndex)
val track = cursor.getInt(trackIndex)
val duration = cursor.getLong(durationIndex)
// Note: Directory parsing is currently disabled until artist images are added.
// val dirs = cursor.getStringOrNull(dataIndex)?.run {
// substringBeforeLast("/", "").ifEmpty { null }
// }
songs.add(
Song(
@ -176,29 +201,28 @@ class MusicLoader {
duration,
track,
id,
year,
album,
albumId,
artist,
albumArtist,
albumId,
album,
year,
)
)
}
}
// Deduplicate songs to prevent (most) deformed music clones
songs = songs.distinctBy {
it.name to it._mediaStoreAlbumName to it._mediaStoreArtistName to it._mediaStoreAlbumArtistName to it.track to it.duration
it.name to it.internalMediaStoreAlbumName to it.internalMediaStoreArtistName to
it.internalMediaStoreAlbumArtistName to it.track to it.duration
}.toMutableList()
logD("Successfully loaded ${songs.size} songs")
return songs
}
private fun buildAlbums(songs: List<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:
// 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN".
// This makes sure both of those are resolved into a single artist called "Rammstein"
@ -209,9 +233,7 @@ class MusicLoader {
// the template, but it seems to work pretty well.
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { song ->
val albumName = song._mediaStoreAlbumName
val artistName = song.resolveAlbumArtistName()
Pair(albumName.lowercase(), artistName.lowercase())
song.internalGroupingId
}
for (entry in songsByAlbum) {
@ -220,14 +242,17 @@ class MusicLoader {
// Use the song with the latest year as our metadata song.
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives.
val templateSong = requireNotNull(albumSongs.maxByOrNull { it._mediaStoreYear })
val albumName = templateSong._mediaStoreAlbumName
val albumYear = templateSong._mediaStoreYear
// TODO: Weigh songs with null years lower than songs with zero years
val templateSong = requireNotNull(
albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 }
)
val albumName = templateSong.internalMediaStoreAlbumName
val albumYear = templateSong.internalMediaStoreYear
val albumCoverUri = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
templateSong._mediaStoreAlbumId
templateSong.internalMediaStoreAlbumId
)
val artistName = templateSong.resolveAlbumArtistName()
val artistName = templateSong.internalGroupingArtistName
albums.add(
Album(
@ -240,12 +265,14 @@ class MusicLoader {
)
}
logD("Successfully built ${albums.size} albums")
return albums
}
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._mediaStoreArtistName }
val albumsByArtist = albums.groupBy { it.internalGroupingArtistName }
for (entry in albumsByArtist) {
val artistName = entry.key
@ -255,14 +282,17 @@ class MusicLoader {
}
val artistAlbums = entry.value
// Due to the black magic we do to get a good artist field, the ID is unreliable.
// Take a hash of the artist name instead.
// Album deduplication does not eliminate every case of fragmented artists, do
// we deduplicate in the artist creation step as well.
// Note that we actually don't do this in groupBy. This is generally because using
// a template song may not result in the best possible artist name in all cases.
val previousArtistIndex = artists.indexOfFirst { artist ->
artist.name.lowercase() == artistName.lowercase()
}
if (previousArtistIndex > -1) {
val previousArtist = artists[previousArtistIndex]
logD("Merging duplicate artist into pre-existing artist ${previousArtist.name}")
artists[previousArtistIndex] = Artist(
previousArtist.name,
previousArtist.resolvedName,
@ -279,13 +309,15 @@ class MusicLoader {
}
}
logD("Successfully built ${artists.size} artists")
return artists
}
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
val genreCursor = context.contentResolver.query(
val genreCursor = context.applicationContext.contentResolver.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.Genres._ID,
@ -305,7 +337,7 @@ class MusicLoader {
// so we skip genres that have them.
val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue
val resolvedName = name.getGenreNameCompat() ?: name
val resolvedName = name.genreNameCompat ?: name
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add(
@ -318,7 +350,7 @@ class MusicLoader {
}
}
val songsWithoutGenres = songs.filter { it.genre == null }
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre.
@ -331,6 +363,8 @@ class MusicLoader {
genres.add(unknownGenre)
}
logD("Successfully loaded ${genres.size} genres")
return genres
}
@ -338,7 +372,7 @@ class MusicLoader {
val genreSongs = mutableListOf<Song>()
// Don't even bother blacklisting here as useless iterations are less expensive than IO
val songCursor = context.contentResolver.query(
val songCursor = context.applicationContext.contentResolver.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null
@ -349,15 +383,87 @@ class MusicLoader {
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
songs.find { it._mediaStoreId == id }?.let { song ->
songs.find { it.internalMediaStoreId == id }?.let { song ->
genreSongs.add(song)
}
}
}
// Some genres might be empty due to MediaStore empty.
// Some genres might be empty due to MediaStore insanity.
// If that is the case, we drop them.
return genreSongs.ifEmpty { null }
}
private val String.genreNameCompat: String? get() {
if (isDigitsOnly()) {
// ID3v1, just parse as an integer
return legacyGenreTable.getOrNull(toInt())
}
if (startsWith('(') && endsWith(')')) {
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
// Any genres formatted as "(CHARS)" will be ignored.
val genreInt = substring(1 until lastIndex).toIntOrNull()
if (genreInt != null) {
return legacyGenreTable.getOrNull(genreInt)
}
}
// Current name is fine.
return null
}
companion object {
/**
* The album_artist MediaStore field has existed since at least API 21, but until API
* 30 it was a proprietary extension for Google Play Music and was not documented.
* Since this field probably works on all versions Auxio supports, we suppress the
* warning about using a possibly-unsupported constant.
*/
@Suppress("InlinedApi")
const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
* winamp extensions.
*/
private val legacyGenreTable = arrayOf(
// ID3 Standard
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
"Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack",
"Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance",
"Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop",
"Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic",
"Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta",
"Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret",
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
// Winamp Extensions
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin",
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
"Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music",
"Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam",
"Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall",
"Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "Britpop",
"Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "JPop", "Synthpop",
// Winamp 5.6+ extensions, used by EasyTAG and friends
// The only reason I include this set is because post-rock is a based genre and
// deserves a slot.
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
"Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk",
"Post-Rock", "Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music",
"Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient"
)
}
}

View file

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

View file

@ -18,79 +18,16 @@
package org.oxycblt.auxio.music
import android.content.Context
import android.text.format.DateUtils
import android.widget.TextView
import androidx.core.text.isDigitsOnly
import androidx.databinding.BindingAdapter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getPluralSafe
/**
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
* winamp extensions.
*/
private val ID3_GENRES = arrayOf(
// ID3 Standard
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz",
"Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno",
"Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno",
"Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental",
"Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk",
"Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy",
"Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American",
"Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
// Winamp Extensions
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival",
"Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock",
"Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus",
"Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad",
"Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella",
"Euro-House", "Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie",
"Britpop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal",
"Anime", "JPop", "Synthpop",
// Winamp 5.6+ extensions, used by EasyTAG and friends
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Downtempo",
"Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental", "Garage", "Global",
"IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock", // S I X T Y F I V E
"New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", "Shoegaze", "Space Rock",
"Trop Rock", "World Music", "Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle",
"Podcast", "Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient"
)
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
// --- EXTENSION FUNCTIONS ---
/**
* Convert legacy int-based ID3 genres to their human-readable genre
* @return The named genre for this legacy genre, null if there is no need to parse it
* or if the genre is invalid.
*/
fun String.getGenreNameCompat(): String? {
if (isDigitsOnly()) {
// ID3v1, just parse as an integer
return ID3_GENRES.getOrNull(toInt())
}
if (startsWith('(') && endsWith(')')) {
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
// Any genres formatted as "(CHARS)" will be ignored.
val genreInt = substring(1 until lastIndex).toIntOrNull()
if (genreInt != null) {
return ID3_GENRES.getOrNull(genreInt)
}
}
// Current name is fine.
return null
}
/**
* Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then
@ -98,6 +35,7 @@ fun String.getGenreNameCompat(): String? {
*/
fun Long.toDuration(isElapsed: Boolean): String {
if (!isElapsed && this == 0L) {
logD("Non-elapsed duration is zero, using --:--")
return "--:--"
}
@ -111,24 +49,56 @@ fun Long.toDuration(isElapsed: Boolean): String {
return durationString
}
fun Int.toDate(context: Context): String {
return if (this == 0) {
context.getString(R.string.def_date)
} else {
toString()
}
}
// --- BINDING ADAPTERS ---
/**
* Bind the album + song counts for an artist
*/
@BindingAdapter("artistCounts")
fun TextView.bindArtistCounts(artist: Artist) {
@BindingAdapter("songInfo")
fun TextView.bindSongInfo(song: Song?) {
if (song == null) {
logW("Song was null, not applying info")
return
}
text = context.getString(
R.string.fmt_counts,
R.string.fmt_two,
song.resolvedArtistName,
song.resolvedAlbumName
)
}
@BindingAdapter("albumInfo")
fun TextView.bindAlbumInfo(album: Album?) {
if (album == null) {
logW("Album was null, not applying info")
return
}
text = context.getString(
R.string.fmt_two,
album.resolvedArtistName,
context.getPluralSafe(R.plurals.fmt_song_count, album.songs.size)
)
}
@BindingAdapter("artistInfo")
fun TextView.bindArtistInfo(artist: Artist?) {
if (artist == null) {
logW("Artist was null, not applying info")
return
}
text = context.getString(
R.string.fmt_two,
context.getPluralSafe(R.plurals.fmt_album_count, artist.albums.size),
context.getPluralSafe(R.plurals.fmt_song_count, artist.songs.size)
)
}
@BindingAdapter("genreInfo")
fun TextView.bindGenreInfo(genre: Genre?) {
if (genre == null) {
logW("Genre was null, not applying info")
return
}
text = context.getPluralSafe(R.plurals.fmt_song_count, genre.songs.size)
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,10 +48,10 @@ class PlaybackStateDatabase(context: Context) :
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
private fun nuke(db: SQLiteDatabase) {
logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_STATE")
execSQL("DROP TABLE IF EXISTS $TABLE_NAME_QUEUE")
onCreate(this)
}
}
@ -103,34 +103,6 @@ class PlaybackStateDatabase(context: Context) :
// --- INTERFACE FUNCTIONS ---
/**
* Clear the previously written [SavedState] and write a new one.
*/
fun writeState(state: SavedState) {
assertBackgroundThread()
writableDatabase.transaction {
delete(TABLE_NAME_STATE, null, null)
this@PlaybackStateDatabase.logD("Wiped state db.")
val stateData = ContentValues(10).apply {
put(StateColumns.COLUMN_ID, 0)
put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
put(StateColumns.COLUMN_POSITION, state.position)
put(StateColumns.COLUMN_PARENT_HASH, state.parent?.id)
put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex)
put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.toInt())
put(StateColumns.COLUMN_IS_SHUFFLING, state.isShuffling)
put(StateColumns.COLUMN_LOOP_MODE, state.loopMode.toInt())
}
insert(TABLE_NAME_STATE, null, stateData)
}
logD("Wrote state to database.")
}
/**
* Read the stored [SavedState] from the database, if there is one.
* @param musicStore Required to transform database songs/parents into actual instances
@ -178,11 +150,69 @@ class PlaybackStateDatabase(context: Context) :
isShuffling = cursor.getInt(shuffleIndex) == 1,
loopMode = LoopMode.fromInt(cursor.getInt(loopModeIndex)) ?: LoopMode.NONE,
)
logD("Successfully read playback state: $state")
}
return state
}
/**
* Clear the previously written [SavedState] and write a new one.
*/
fun writeState(state: SavedState) {
assertBackgroundThread()
writableDatabase.transaction {
delete(TABLE_NAME_STATE, null, null)
this@PlaybackStateDatabase.logD("Wiped state db")
val stateData = ContentValues(10).apply {
put(StateColumns.COLUMN_ID, 0)
put(StateColumns.COLUMN_SONG_HASH, state.song?.id)
put(StateColumns.COLUMN_POSITION, state.position)
put(StateColumns.COLUMN_PARENT_HASH, state.parent?.id)
put(StateColumns.COLUMN_QUEUE_INDEX, state.queueIndex)
put(StateColumns.COLUMN_PLAYBACK_MODE, state.playbackMode.toInt())
put(StateColumns.COLUMN_IS_SHUFFLING, state.isShuffling)
put(StateColumns.COLUMN_LOOP_MODE, state.loopMode.toInt())
}
insert(TABLE_NAME_STATE, null, stateData)
}
logD("Wrote state to database")
}
/**
* Read a list of queue items from this database.
* @param musicStore Required to transform database songs into actual song instances
*/
fun readQueue(musicStore: MusicStore): MutableList<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.
*/
@ -190,12 +220,11 @@ class PlaybackStateDatabase(context: Context) :
assertBackgroundThread()
val database = writableDatabase
database.transaction {
delete(TABLE_NAME_QUEUE, null, null)
}
logD("Wiped queue db.")
logD("Wiped queue db")
writeQueueBatch(queue, queue.size)
}
@ -232,32 +261,6 @@ class PlaybackStateDatabase(context: Context) :
}
}
/**
* Read a list of queue items from this database.
* @param musicStore Required to transform database songs into actual song instances
*/
fun readQueue(musicStore: MusicStore): MutableList<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(
val song: Song?,
val position: Long,

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import org.oxycblt.auxio.util.logD
/**
* Some apps like to party like it's 2011 and just blindly query for the ACTION_MEDIA_BUTTON
@ -20,6 +21,7 @@ import androidx.core.content.ContextCompat
class MediaButtonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
logD("Received external media button intent")
intent.component = ComponentName(context, PlaybackService::class.java)
ContextCompat.startForegroundService(context, intent)
}

View file

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

View file

@ -180,7 +180,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
settingsManager.addCallback(this)
logD("Service created.")
logD("Service created")
}
override fun onDestroy() {
@ -207,7 +207,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
serviceJob.cancel()
}
logD("Service destroyed.")
logD("Service destroyed")
}
// --- PLAYER EVENT LISTENER OVERRIDES ---
@ -260,22 +260,21 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onSongUpdate(song: Song?) {
if (song != null) {
logD("Setting player to ${song.name}")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
notification.setMetadata(song, ::startForegroundOrNotify)
return
}
// Clear if there's nothing to play.
logD("Nothing playing, stopping playback")
player.stop()
stopForegroundAndNotification()
}
override fun onParentUpdate(parent: MusicParent?) {
notification.setParent(parent)
startForegroundOrNotify()
}
@ -295,7 +294,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onLoopUpdate(loopMode: LoopMode) {
if (!settingsManager.useAltNotifAction) {
notification.setLoop(loopMode)
startForegroundOrNotify()
}
}
@ -303,7 +301,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShuffleUpdate(isShuffling: Boolean) {
if (settingsManager.useAltNotifAction) {
notification.setShuffle(isShuffling)
startForegroundOrNotify()
}
}
@ -334,7 +331,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
override fun onShowCoverUpdate(showCovers: Boolean) {
playbackManager.song?.let { song ->
connector.onSongUpdate(song)
notification.setMetadata(song, ::startForegroundOrNotify)
}
}
@ -443,7 +439,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
private fun stopForegroundAndNotification() {
stopForeground(true)
notificationManager.cancel(PlaybackNotification.NOTIFICATION_ID)
isForeground = false
}
@ -451,25 +446,36 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
* A [BroadcastReceiver] for receiving general playback events from the system.
*/
private inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// --- SYSTEM EVENTS ---
// Technically the MediaSession seems to handle bluetooth events on their
// own, but keep this around as a fallback in the case that the former fails
// for whatever reason.
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) {
AudioManager.SCO_AUDIO_STATE_CONNECTED -> resumeFromPlug()
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug()
AudioManager.SCO_AUDIO_STATE_CONNECTED -> maybeResumeFromPlug()
}
}
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// MediaSession does not handle wired headsets for some reason, so also include
// this. Gotta love Android having two actions for more or less the same thing.
AudioManager.ACTION_HEADSET_PLUG -> {
when (intent.getIntExtra("state", -1)) {
0 -> resumeFromPlug()
1 -> pauseFromPlug()
0 -> pauseFromPlug()
1 -> maybeResumeFromPlug()
}
initialHeadsetPlugEventHandled = true
}
// I have never seen this ever happen but it might be useful
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> playbackManager.setPlaying(
!playbackManager.isPlaying
@ -494,25 +500,35 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
}
}
}
/**
* Resume from a headset plug event, as long as its allowed.
*/
private fun resumeFromPlug() {
if (playbackManager.song != null && settingsManager.doPlugMgt) {
logD("Device connected, resuming...")
playbackManager.setPlaying(true)
/**
* Resume from a headset plug event in the case that the quirk is enabled.
* This functionality remains a quirk for two reasons:
* 1. Automatically resuming more or less overrides all other audio streams, which
* is not that friendly
* 2. There is a bug where playback will always start when this service starts, mostly
* due to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but
* I fear that it may not work on OEM skins that for whatever reason don't make this
* action fire.
*/
private fun maybeResumeFromPlug() {
if (playbackManager.song != null &&
settingsManager.headsetAutoplay &&
initialHeadsetPlugEventHandled
) {
logD("Device connected, resuming")
playbackManager.setPlaying(true)
}
}
}
/**
* Pause from a headset plug, as long as its allowed.
*/
private fun pauseFromPlug() {
if (playbackManager.song != null && settingsManager.doPlugMgt) {
logD("Device disconnected, pausing...")
playbackManager.setPlaying(false)
/**
* Pause from a headset plug.
*/
private fun pauseFromPlug() {
if (playbackManager.song != null) {
logD("Device disconnected, pausing")
playbackManager.setPlaying(false)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,8 +30,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.showToast
@ -39,11 +39,11 @@ import org.oxycblt.auxio.util.showToast
/**
* Extension method for creating and showing a new [ActionMenu].
* @param anchor [View] This should be centered around
* @param data [BaseModel] this menu corresponds to
* @param data [Item] this menu corresponds to
* @param flag (Optional, defaults to [ActionMenu.FLAG_NONE]) Any extra flags to accompany the data.
* @see ActionMenu
*/
fun Fragment.newMenu(anchor: View, data: BaseModel, flag: Int = ActionMenu.FLAG_NONE) {
fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE) {
ActionMenu(requireActivity() as AppCompatActivity, anchor, data, flag).show()
}
@ -51,15 +51,18 @@ fun Fragment.newMenu(anchor: View, data: BaseModel, flag: Int = ActionMenu.FLAG_
* A wrapper around [PopupMenu] that automates the menu creation for nearly every datatype in Auxio.
* @param activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around
* @param data [BaseModel] this menu corresponds to
* @param data [Item] this menu corresponds to
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @throws IllegalStateException When there is no menu for this specific datatype/flag
* @author OxygenCobalt
* TODO: Stop scrolling when a menu is open
* TODO: Prevent duplicate menus from showing up
* TODO: Maybe replace this with a bottom sheet?
*/
class ActionMenu(
activity: AppCompatActivity,
anchor: View,
private val data: BaseModel,
private val data: Item,
private val flag: Int
) : PopupMenu(activity, anchor) {
private val context = activity.applicationContext

View file

@ -19,14 +19,14 @@
package org.oxycblt.auxio.ui
import androidx.recyclerview.widget.DiffUtil
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Item
/**
* A re-usable diff callback for all [BaseModel] implementations.
* A re-usable diff callback for all [Item] implementations.
* **Use this instead of creating a DiffCallback for each adapter.**
* @author OxygenCobalt
*/
class DiffCallback<T : BaseModel> : DiffUtil.ItemCallback<T>() {
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}

View file

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

View file

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

View file

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

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 }
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
album.songs.intSort(true) { it.track }
album.songs.intSort(true) { it.track ?: 0 }
}
}
}
@ -121,7 +121,7 @@ sealed class Sort(open val isAscending: Boolean) {
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
.flatMap { ByYear(false).sortAlbums(it.albums) }
is ByYear -> albums.intSort { it.year }
is ByYear -> albums.intSort { it.year ?: 0 }
}
}
@ -139,7 +139,7 @@ sealed class Sort(open val isAscending: Boolean) {
* @see sortSongs
*/
fun sortAlbum(album: Album): List<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.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Header
import org.oxycblt.auxio.music.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders.
* @param T The datatype, inheriting [BaseModel] for this ViewHolder.
* @param T The datatype, inheriting [Item] for this ViewHolder.
* @param binding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @param doOnClick (Optional) Function that calls on a click.
* @param doOnLongClick (Optional) Functions that calls on a long-click.
* @author OxygenCobalt
*/
abstract class BaseViewHolder<T : BaseModel>(
abstract class BaseViewHolder<T : Item>(
private val binding: ViewDataBinding,
private val doOnClick: ((data: T) -> Unit)? = null,
private val doOnLongClick: ((view: View, data: T) -> Unit)? = null
@ -59,7 +59,7 @@ abstract class BaseViewHolder<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.
* @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.StringRes
import androidx.core.content.ContextCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainActivity
import kotlin.reflect.KClass
import kotlin.system.exitProcess
@ -75,8 +74,7 @@ fun Context.getPluralSafe(@PluralsRes pluralsRes: Int, value: Int): String {
return try {
resources.getQuantityString(pluralsRes, value, value)
} catch (e: Exception) {
logE("plural load failed")
return "<plural error>"
handleResourceFailure(e, "plural", "<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 {
logE("$what load failed.")
if (BuildConfig.DEBUG) {
// I'd rather be aware of a sudden crash when debugging.
throw e
} else {
// Not so much when the app is in production.
logE(e.stackTraceToString())
return default
}
logE("$what load failed")
e.logTraceOrThrow()
return default
}
/**

View file

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

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
*/
@ -49,18 +56,30 @@ fun Any.logE(msg: String) {
}
/**
* Get a non-nullable name, used so that logs will always show up in the console.
* This also applies a special "Auxio" prefix so that messages can be filtered to just from the main codebase.
* Logs an error in production while still throwing it in debug mode. This is useful for
* non-showstopper bugs that I would still prefer to be caught in debug mode.
*/
fun Throwable.logTraceOrThrow() {
if (BuildConfig.DEBUG) {
throw this
} else {
logE(stackTraceToString())
}
}
/**
* Get a non-nullable name, used so that logs will always show up by Auxio
* @return The name of the object, otherwise "Anonymous Object"
*/
private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymous Object"}"
/**
* I know that this will not stop you, but consider what you are doing with your life, copiers.
* I know that this will not stop you, but consider what you are doing with your life, plagiarizers.
* Do you want to live a fulfilling existence on this planet? Or do you want to spend your life
* taking work others did and making it objectively worse so you could arbitrage a fraction of a
* penny on every AdMob impression you get? You could do so many great things if you simply had
* the courage to come up with an idea of your own. Be better.
* the courage to come up with an idea of your own. If you still want to go on, I guess the only
* thing I can say is this: JUNE 1989 TIANAMEN SQUARE PROTESTS AND MASSACRE 六四事件
*/
private fun basedCopyleftNotice() {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&

View file

@ -22,6 +22,7 @@ import android.content.res.ColorStateList
import android.graphics.Insets
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.WindowInsets
import androidx.annotation.ColorRes
import androidx.recyclerview.widget.GridLayoutManager
@ -63,7 +64,20 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height
/**
* Resolve window insets in a version-aware manner. This can be used to apply padding to
* Disables drop shadows on a view programmatically in a version-compatible manner.
* This only works on Android 9 and above. Below that version, shadows will remain visible.
*/
fun View.disableDropShadowCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
logD("Disabling drop shadows")
val transparent = context.getColorSafe(android.R.color.transparent)
outlineAmbientShadowColor = transparent
outlineSpotShadowColor = transparent
}
}
/**
* Resolve system bar insets in a version-aware manner. This can be used to apply padding to
* a view that properly follows all the frustrating changes that were made between 8-11.
*/
val WindowInsets.systemBarInsetsCompat: Rect get() {
@ -86,7 +100,11 @@ val WindowInsets.systemBarInsetsCompat: Rect get() {
}
}
fun WindowInsets.replaceInsetsCompat(left: Int, top: Int, right: Int, bottom: Int): WindowInsets {
/**
* Replaces the system bar insets in a version-aware manner. This can be used to modify the insets
* for child views in a way that follows all of the frustrating changes that were made between 8-11.
*/
fun WindowInsets.replaceSystemBarInsetsCompat(left: Int, top: Int, right: Int, bottom: Int): WindowInsets {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
WindowInsets.Builder(this)

View file

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

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

View file

@ -34,11 +34,13 @@ import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getDimenSizeSafe
import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import kotlin.math.min
/**
@ -86,32 +88,39 @@ class WidgetProvider : AppWidgetProvider() {
}
}
/**
* Custom function for loading bitmaps to the widget in a way that works with the
* widget ImageView instances.
*/
private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
// Load our image so that it takes up the phone screen. This allows
// us to get stable rounded corners for every single widget image. This probably
// sacrifices quality in some way, but it's really the only good option.
val metrics = context.resources.displayMetrics
val imageSize = min(metrics.widthPixels, metrics.heightPixels)
val coverRequest = ImageRequest.Builder(context)
.data(song.album)
.size(imageSize)
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
// If we are on Android 12 or higher, round out the album cover.
// This is simply to maintain stylistic cohesion with other widgets.
// Here, we actually have to use RoundedCornersTransformation since the way
// we get a 1:1 aspect ratio image results in clipToOutline not working well.
// The widget has two distinct styles that we must transform the album art to accommodate:
// - Before Android 12, the widget has hard edges, so we don't need to round out the album
// art.
// - After Android 12, the widget has round edges, so we need to round out the album art.
// I dislike this, but it's mainly for stylistic cohesion.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use RoundedCornersTransformation. This is because our hack to get a 1:1 aspect
// ratio on widget ImageViews doesn't actually result in a square ImageView, so
// clipToOutline won't work.
val transform = RoundedCornersTransformation(
context.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
.toFloat()
)
coverRequest.transformations(transform)
// The output of RoundedCornersTransformation is dimension-dependent, so scale up the
// image to the screen size to ensure consistent radii.
val metrics = context.resources.displayMetrics
coverRequest.transformations(SquareFrameTransform(), transform)
.size(min(metrics.widthPixels, metrics.heightPixels))
} else {
coverRequest.transformations(SquareFrameTransform())
}
context.imageLoader.enqueue(coverRequest.build())
@ -148,6 +157,8 @@ class WidgetProvider : AppWidgetProvider() {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
logD("Requesting new view from PlaybackService")
// We can't resize the widget until we can generate the views, so request an update
// from PlaybackService.
requestUpdate(context)
@ -214,7 +225,6 @@ class WidgetProvider : AppWidgetProvider() {
// Find the layout with the greatest area that fits entirely within
// the widget. This is what we will use.
val candidates = mutableListOf<SizeF>()
for (size in views.keys) {
@ -231,7 +241,7 @@ class WidgetProvider : AppWidgetProvider() {
continue
} else {
// Default to the smallest view if no layout fits
logD("No widget layout found")
logW("No good widget layout found")
val minimum = requireNotNull(
views.minByOrNull { it.key.width * it.key.height }?.value

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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/colorSurfaceVariant" />
</selector>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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/colorOnSurfaceVariant" />
</selector>

View file

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

View file

@ -2,9 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<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" />
</vector>

View file

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