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