diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml
index 614c45a41..d34ce4da9 100644
--- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml
@@ -72,6 +72,8 @@ body:
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
5. Copy and paste the output to this area of the issue.
render: shell
+ validations:
+ required: true
- type: checkboxes
id: terms
attributes:
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 000000000..448ac933e
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,38 @@
+name: Android CI
+
+on:
+ push:
+ branches: [ "dev" ]
+ pull_request:
+ branches: [ "dev" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK 11
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ cache: gradle
+ - name: Set up NDK r21e
+ uses: nttld/setup-ndk@v1.2.0
+ id: setup-ndk
+ with:
+ ndk-version: r21e
+ add-to-path: false
+ - run: python3 prebuild.py
+ env:
+ ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build debug APK with Gradle
+ run: ./gradlew app:packageDebug
+ - name: Upload debug APK artifact
+ uses: actions/upload-artifact@v3.1.1
+ with:
+ name: Auxio_Canary
+ path: ./app/build/outputs/apk/debug/app-debug.apk
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cc7f6db2..f0d47c38f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,26 @@
# Changelog
-## dev
+## 3.0.1
+
+#### What's New
+- Added support for album date ranges (ex. 2010 - 2013)
+
+#### What's Improved
+- Formalized whitespace handling
+- Value lists are now properly localized
+- Queue no longer primarily shows previous songs when opened
+- Added reset button to ReplayGain pre-amp configuration dialog
+
+#### What's Changed
+- R128 ReplayGain tags are now only used when playing OPUS files
+
+#### What's Fixed
+- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
+- Fixed crash when playing certain MP3 files
+- Detail UI will no longer crash if the music library is unavailable
+
+#### Dev/Meta
+- Add CI workflow
## 3.0.0
@@ -12,7 +32,7 @@
- Added setting to hide "collaborator" artists
- Upgraded music ID management:
- Added support for MusicBrainz IDs (MBIDs)
- - Use the more unique MD5 hash of metadata when MBIDs can't be used
+ - Use a more unique hash of metadata when MBIDs can't be used
- Genres now display a list of artists
- Added toggle to load non-music (Such as podcasts)
- Music loader now caches parsed metadata for faster load times
@@ -42,7 +62,6 @@ audio focus was lost
#### What's Changed
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
-- Removed the "Play from genre" option in the library/detail playback mode settings+
- "Use alternate notification action" is now "Custom notification action"
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
diff --git a/README.md b/README.md
index d1b95cc36..6ade65990 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Auxio
A simple, rational music player for android.
-
-
+
+
@@ -79,7 +79,9 @@ Auxio relies on a custom version of ExoPlayer that enables some extra features.
Auxio accepts most contributions as long as they follow the [Contribution Guidelines](/.github/CONTRIBUTING.md).
-However, feature additions and major UI changes are less likely to be accepted. See [Accepted Additions](/info/ADDITIONS.md) for more information.
+However, feature additions and major UI changes are less likely to be accepted. See
+[Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F)
+for more information.
## License
diff --git a/app/build.gradle b/app/build.gradle
index ef8f54ab5..b0662424f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,8 +12,8 @@ android {
defaultConfig {
applicationId namespace
- versionName "3.0.0"
- versionCode 24
+ versionName "3.0.1"
+ versionCode 25
minSdk 21
targetSdk 33
@@ -121,3 +121,7 @@ spotless {
licenseHeaderFile("NOTICE")
}
}
+
+afterEvaluate {
+ preDebugBuild.dependsOn spotlessApply
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index f3c0f68db..b63d5e026 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -21,5 +21,5 @@
#-renamesourcefileattribute SourceFile
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
-# Also it's easier to debug if the class names remain unmangled.
+# Also it's easier to fix issues if the stack trace symbols remain unmangled.
-dontobfuscate
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 26eacc16f..1c35add27 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -116,7 +116,7 @@
-
+
- binding.context.getDimen(R.dimen.elevation_normal)
- }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -78,6 +75,8 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
+ elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
+
// --- UI SETUP ---
val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing
@@ -217,7 +216,7 @@ class MainFragment :
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
}
- // Prevent interactions when the playback panell fully fades out.
+ // Prevent interactions when the playback panel fully fades out.
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
binding.queueSheet.apply {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
index 639687f45..d5b32e584 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
@@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.storage.MimeType
+import org.oxycblt.auxio.music.filesystem.MimeType
/**
* A header variation that displays a button to open a sort menu.
@@ -35,21 +35,13 @@ data class SortHeader(@StringRes val titleRes: Int) : Item
data class DiscHeader(val disc: Int) : Item
/**
- * A [Song] extension that adds information about it's file properties.
- * @param song The internal song
- * @param properties The properties of the song file. Null if parsing is ongoing.
+ * The properties of a [Song]'s file.
+ * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
+ * @param sampleRateHz The sample rate, in hertz.
+ * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
*/
-data class DetailSong(val song: Song, val properties: Properties?) {
- /**
- * The properties of a [Song]'s file.
- * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
- * @param sampleRateHz The sample rate, in hertz.
- * @param resolvedMimeType The known mime type of the [Song] after it's file format was
- * determined.
- */
- data class Properties(
- val bitrateKbps: Int?,
- val sampleRateHz: Int?,
- val resolvedMimeType: MimeType
- )
-}
+data class SongProperties(
+ val bitrateKbps: Int?,
+ val sampleRateHz: Int?,
+ val resolvedMimeType: MimeType
+)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
index 8e4a4ed6d..7f3d9f773 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
@@ -31,13 +31,13 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.ui.AuxioAppBarLayout
+import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
/**
- * An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
- * beyond it's first item.
+ * An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
+ * view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
@@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.lazyReflectedField
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
- AuxioAppBarLayout(context, attrs, defStyleAttr) {
+ CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
@@ -166,8 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
- companion object {
- private val TOOLBAR_TITLE_TEXT_FIELD: Field by
- lazyReflectedField(Toolbar::class, "mTitleTextView")
+ private companion object {
+ val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index f350fcba1..2f7a404e0 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
-import org.oxycblt.auxio.music.storage.MimeType
+import org.oxycblt.auxio.music.filesystem.MimeType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.*
@@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailViewModel(application: Application) :
- AndroidViewModel(application), MusicStore.Callback {
+ AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application)
@@ -59,15 +59,15 @@ class DetailViewModel(application: Application) :
// --- SONG ---
- private val _currentSong = MutableStateFlow(null)
- /**
- * The current [DetailSong] to display. Null if there is nothing to show.
- *
- * TODO: De-couple Song and Properties?
- */
- val currentSong: StateFlow
+ private val _currentSong = MutableStateFlow(null)
+ /** The current [Song] to display. Null if there is nothing to show. */
+ val currentSong: StateFlow
get() = _currentSong
+ private val _songProperties = MutableStateFlow(null)
+ /** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
+ val songProperties: StateFlow = _songProperties
+
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow(null)
@@ -130,11 +130,11 @@ class DetailViewModel(application: Application) :
}
init {
- musicStore.addCallback(this)
+ musicStore.addListener(this)
}
override fun onCleared() {
- musicStore.removeCallback(this)
+ musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -149,13 +149,8 @@ class DetailViewModel(application: Application) :
val song = currentSong.value
if (song != null) {
- val newSong = library.sanitize(song.song)
- if (newSong != null) {
- loadDetailSong(newSong)
- } else {
- _currentSong.value = null
- }
- logD("Updated song to $newSong")
+ _currentSong.value = library.sanitize(song)?.also(::loadProperties)
+ logD("Updated song to ${currentSong.value}")
}
val album = currentAlbum.value
@@ -178,17 +173,17 @@ class DetailViewModel(application: Application) :
}
/**
- * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading
- * process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
+ * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
+ * and [songProperties] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
- if (_currentSong.value?.run { song.uid } == uid) {
+ if (_currentSong.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Song [uid: $uid]")
- loadDetailSong(requireMusic(uid))
+ _currentSong.value = requireMusic(uid)?.also(::loadProperties)
}
/**
@@ -202,7 +197,7 @@ class DetailViewModel(application: Application) :
return
}
logD("Opening Album [uid: $uid]")
- _currentAlbum.value = requireMusic(uid).also { refreshAlbumList(it) }
+ _currentAlbum.value = requireMusic(uid)?.also(::refreshAlbumList)
}
/**
@@ -216,7 +211,7 @@ class DetailViewModel(application: Application) :
return
}
logD("Opening Artist [uid: $uid]")
- _currentArtist.value = requireMusic(uid).also { refreshArtistList(it) }
+ _currentArtist.value = requireMusic(uid)?.also(::refreshArtistList)
}
/**
@@ -230,29 +225,29 @@ class DetailViewModel(application: Application) :
return
}
logD("Opening Genre [uid: $uid]")
- _currentGenre.value = requireMusic(uid).also { refreshGenreList(it) }
+ _currentGenre.value = requireMusic(uid)?.also(::refreshGenreList)
}
- private fun requireMusic(uid: Music.UID): T =
- requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
+ private fun requireMusic(uid: Music.UID) = musicStore.library?.find(uid)
/**
- * Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file.
+ * Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
+ * [songProperties].
* @param song The song to load.
*/
- private fun loadDetailSong(song: Song) {
+ private fun loadProperties(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
- _currentSong.value = DetailSong(song, null)
+ _songProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
- val info = loadProperties(song)
+ val properties = this@DetailViewModel.loadPropertiesImpl(song)
yield()
- _currentSong.value = DetailSong(song, info)
+ _songProperties.value = properties
}
}
- private fun loadProperties(song: Song): DetailSong.Properties {
+ private fun loadPropertiesImpl(song: Song): SongProperties {
// While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
@@ -266,7 +261,7 @@ class DetailViewModel(application: Application) :
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
- return DetailSong.Properties(null, null, song.mimeType)
+ return SongProperties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
@@ -310,7 +305,7 @@ class DetailViewModel(application: Application) :
MimeType(song.mimeType.fromExtension, formatMimeType)
}
- return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
+ return SongProperties(bitrate, sampleRate, resolvedMimeType)
}
private fun refreshAlbumList(album: Album) {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
index e7444ab16..cf2d516d7 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
@@ -26,6 +26,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
+import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
@@ -53,10 +54,10 @@ class SongDetailDialog : ViewBindingDialogFragment() {
super.onBindingCreated(binding, savedInstanceState)
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid)
- collectImmediately(detailModel.currentSong, ::updateSong)
+ collectImmediately(detailModel.currentSong, detailModel.songProperties, ::updateSong)
}
- private fun updateSong(song: DetailSong?) {
+ private fun updateSong(song: Song?, properties: SongProperties?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
@@ -64,28 +65,28 @@ class SongDetailDialog : ViewBindingDialogFragment() {
}
val binding = requireBinding()
- if (song.properties != null) {
+ if (properties != null) {
// Finished loading Song properties, populate and show the list of Song information.
binding.detailLoading.isInvisible = true
binding.detailContainer.isInvisible = false
val context = requireContext()
- binding.detailFileName.setText(song.song.path.name)
- binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
- binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
- binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
- binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
+ binding.detailFileName.setText(song.path.name)
+ binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
+ binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
+ binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
+ binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
- if (song.properties.bitrateKbps != null) {
+ if (properties.bitrateKbps != null) {
binding.detailBitrate.setText(
- getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
+ getString(R.string.fmt_bitrate, properties.bitrateKbps))
} else {
binding.detailBitrate.setText(R.string.def_bitrate)
}
- if (song.properties.sampleRateHz != null) {
+ if (properties.sampleRateHz != null) {
binding.detailSampleRate.setText(
- getString(R.string.fmt_sample_rate, song.properties.sampleRateHz))
+ getString(R.string.fmt_sample_rate, properties.sampleRateHz))
} else {
binding.detailSampleRate.setText(R.string.def_sample_rate)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
index 8d1eb25ec..8cc214b14 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
@@ -67,9 +67,9 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.new(parent)
- DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.new(parent)
- AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.new(parent)
+ AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
+ DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
+ AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@@ -88,9 +88,9 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
}
- companion object {
+ private companion object {
/** A comparator that can be used with DiffUtil. */
- private val DIFF_CALLBACK =
+ val DIFF_CALLBACK =
object : SimpleItemCallback- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
@@ -110,7 +110,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
/**
- * A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -142,7 +142,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
- val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date)
+ val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
@@ -161,7 +161,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
@@ -170,7 +170,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) &&
- oldItem.date == newItem.date &&
+ oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
oldItem.type == newItem.type
@@ -180,7 +180,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
/**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
- * [new] to create an instance.
+ * [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
@@ -202,7 +202,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
@@ -215,7 +215,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -227,7 +227,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
* @param listener A [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
- listener.bind(this, song, binding.songMenu)
+ listener.bind(song, this, menuButton = binding.songMenu)
binding.songTrack.apply {
if (song.track != null) {
@@ -269,7 +269,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
index 12b9b2fd4..84aedabfb 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
@@ -54,9 +54,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.new(parent)
- ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.new(parent)
- ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.new(parent)
+ ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
+ ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
+ ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@@ -76,9 +76,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
return super.isItemFullWidth(position) || item is Artist
}
- companion object {
+ private companion object {
/** A comparator that can be used with DiffUtil. */
- private val DIFF_CALLBACK =
+ val DIFF_CALLBACK =
object : SimpleItemCallback
- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
@@ -97,7 +97,7 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
}
/**
- * A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -156,7 +156,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
@@ -172,7 +172,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
}
/**
- * A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -184,12 +184,13 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
- listener.bind(this, album, binding.parentMenu)
+ listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
- album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date)
+ album.dates?.resolveDate(binding.context)
+ ?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -210,20 +211,20 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
- oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
+ oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
}
}
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -235,7 +236,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
- listener.bind(this, song, binding.songMenu)
+ listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.album.resolveName(binding.context)
@@ -259,7 +260,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
index dcb21b67a..8775f4b9b 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
@@ -57,8 +57,8 @@ abstract class DetailAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
- SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.new(parent)
+ HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
+ SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType")
}
@@ -109,7 +109,7 @@ abstract class DetailAdapter(
fun onOpenSortMenu(anchor: View)
}
- companion object {
+ protected companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback
- () {
@@ -128,7 +128,7 @@ abstract class DetailAdapter(
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
- * button opening a menu for sorting. Use [new] to create an instance.
+ * button opening a menu for sorting. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
@@ -157,7 +157,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
index 6d6326dc7..fad27aa8a 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt
@@ -54,9 +54,9 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent)
- ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
- SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
+ GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
+ ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
+ SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@@ -75,7 +75,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return super.isItemFullWidth(position) || item is Genre
}
- companion object {
+ private companion object {
val DIFF_CALLBACK =
object : SimpleItemCallback
- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
@@ -94,7 +94,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
/**
- * A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [new] to
+ * A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -130,7 +130,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index 3e6795c5a..efa3d86f6 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -49,14 +49,7 @@ import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.selection.SelectionFragment
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.MusicMode
-import org.oxycblt.auxio.music.MusicViewModel
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.Sort
+import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
@@ -72,17 +65,7 @@ class HomeFragment :
private val homeModel: HomeViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
-
- // lifecycleObject builds this in the creation step, so doing this is okay.
- private val storagePermissionLauncher: ActivityResultLauncher by lifecycleObject {
- registerForActivityResult(ActivityResultContracts.RequestPermission()) {
- musicModel.refresh()
- }
- }
-
- private val sortItem: MenuItem by lifecycleObject { binding ->
- binding.homeToolbar.menu.findItem(R.id.submenu_sorting)
- }
+ private var storagePermissionLauncher: ActivityResultLauncher? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -105,6 +88,12 @@ class HomeFragment :
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
+ // Have to set up the permission launcher before the view is shown
+ storagePermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ musicModel.refresh()
+ }
+
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(this)
@@ -171,6 +160,7 @@ class HomeFragment :
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
+ storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null)
}
@@ -285,14 +275,16 @@ class HomeFragment :
}
}
- val sortMenu = requireNotNull(sortItem.subMenu)
+ val sortMenu =
+ unlikelyToBeNull(
+ requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
// Check the ascending option and corresponding sort option to align with
// the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId ||
- (option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
+ (option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
option.isChecked = true
}
@@ -303,7 +295,13 @@ class HomeFragment :
// Update the scrolling view in AppBarLayout to align with the current tab's
// scrolling state. This prevents the lift state from being confused as one
// goes between different tabs.
- requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode)
+ requireBinding().homeAppbar.liftOnScrollTargetViewId =
+ when (tabMode) {
+ MusicMode.SONGS -> R.id.home_song_recycler
+ MusicMode.ALBUMS -> R.id.home_album_recycler
+ MusicMode.ARTISTS -> R.id.home_artist_recycler
+ MusicMode.GENRES -> R.id.home_genre_recycler
+ }
}
private fun handleRecreate(recreate: Boolean) {
@@ -321,9 +319,12 @@ class HomeFragment :
}
private fun updateIndexerState(state: Indexer.State?) {
+ // TODO: Make music loading experience a bit more pleasant
+ // 1. Loading placeholder for item lists
+ // 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
- is Indexer.State.Complete -> setupCompleteState(binding, state.response)
+ is Indexer.State.Complete -> setupCompleteState(binding, state.result)
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
null -> {
logD("Indexer is in indeterminate state")
@@ -332,53 +333,56 @@ class HomeFragment :
}
}
- private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
- if (response is Indexer.Response.Ok) {
+ private fun setupCompleteState(
+ binding: FragmentHomeBinding,
+ result: Result
+ ) {
+ if (result.isSuccess) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
} else {
logD("Received non-ok response")
val context = requireContext()
+ val throwable = unlikelyToBeNull(result.exceptionOrNull())
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
- when (response) {
- is Indexer.Response.Err -> {
- logD("Updating UI to Response.Err state")
- binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
-
- // Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
- text = context.getString(R.string.lbl_retry)
- setOnClickListener { musicModel.refresh() }
- }
- }
- is Indexer.Response.NoMusic -> {
- logD("Updating UI to Response.NoMusic state")
- binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
-
- // Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
- text = context.getString(R.string.lbl_retry)
- setOnClickListener { musicModel.refresh() }
- }
- }
- is Indexer.Response.NoPerms -> {
- logD("Updating UI to Response.NoPerms state")
+ when (throwable) {
+ is Indexer.NoPermissionException -> {
+ logD("Updating UI to permission request state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
-
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant)
setOnClickListener {
- storagePermissionLauncher.launch(Indexer.PERMISSION_READ_AUDIO)
+ requireNotNull(storagePermissionLauncher) {
+ "Permission launcher was not available"
+ }
+ .launch(Indexer.PERMISSION_READ_AUDIO)
}
}
}
- else -> {}
+ is Indexer.NoMusicException -> {
+ logD("Updating UI to no music state")
+ binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
+ // Configure the action to act as a reload trigger.
+ binding.homeIndexingAction.apply {
+ visibility = View.VISIBLE
+ text = context.getString(R.string.lbl_retry)
+ setOnClickListener { musicModel.refresh() }
+ }
+ }
+ else -> {
+ logD("Updating UI to error state")
+ binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
+ // Configure the action to act as a reload trigger.
+ binding.homeIndexingAction.apply {
+ visibility = View.VISIBLE
+ text = context.getString(R.string.lbl_retry)
+ setOnClickListener { musicModel.rescan() }
+ }
+ }
}
}
}
@@ -438,10 +442,9 @@ class HomeFragment :
val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
+ // New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
- // Significant enough change where we want to expand the RecyclerView
- binding.homeAppbar.expandWithRecycler(
- binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
+ binding.homeAppbar.expandWithScrollingRecycler()
}
}
@@ -457,20 +460,6 @@ class HomeFragment :
reenterTransition = MaterialSharedAxis(axis, false)
}
- /**
- * Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
- * [MusicMode].
- * @param tabMode The [MusicMode] of the tab.
- * @return The ID of the RecyclerView contained by the given tab.
- */
- private fun getTabRecyclerId(tabMode: MusicMode) =
- when (tabMode) {
- MusicMode.SONGS -> R.id.home_song_recycler
- MusicMode.ALBUMS -> R.id.home_album_recycler
- MusicMode.ARTISTS -> R.id.home_artist_recycler
- MusicMode.GENRES -> R.id.home_genre_recycler
- }
-
/**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
* @param tabs The current tab configuration. This will define the [Fragment]s created.
@@ -493,12 +482,10 @@ class HomeFragment :
}
}
- companion object {
- private val VP_RECYCLER_FIELD: Field by
- lazyReflectedField(ViewPager2::class, "mRecyclerView")
- private val RV_TOUCH_SLOP_FIELD: Field by
- lazyReflectedField(RecyclerView::class, "mTouchSlop")
- private const val KEY_LAST_TRANSITION_AXIS =
+ private companion object {
+ val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
+ val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
+ const val KEY_LAST_TRANSITION_AXIS =
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index d6be75fd4..f9e88e3ef 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -18,6 +18,7 @@
package org.oxycblt.auxio.home
import android.app.Application
+import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,9 +40,11 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt)
*/
class HomeViewModel(application: Application) :
- AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
+ AndroidViewModel(application),
+ MusicStore.Listener,
+ SharedPreferences.OnSharedPreferenceChangeListener {
private val musicStore = MusicStore.getInstance()
- private val settings = Settings(application, this)
+ private val settings = Settings(application)
private val _songsList = MutableStateFlow(listOf())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
@@ -91,13 +94,14 @@ class HomeViewModel(application: Application) :
val isFastScrolling: StateFlow = _isFastScrolling
init {
- musicStore.addCallback(this)
+ musicStore.addListener(this)
+ settings.addListener(this)
}
override fun onCleared() {
super.onCleared()
- musicStore.removeCallback(this)
- settings.release()
+ musicStore.removeListener(this)
+ settings.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -119,7 +123,7 @@ class HomeViewModel(application: Application) :
}
}
- override fun onSettingChanged(key: String) {
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
context.getString(R.string.set_key_lib_tabs) -> {
// Tabs changed, update the current tabs and set up a re-create event.
diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt
index ba1de4483..da7cd4554 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt
@@ -169,8 +169,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
}
}
- companion object {
+ private companion object {
// Pre-calculate sqrt(2)
- private const val SQRT2 = 1.4142135f
+ const val SQRT2 = 1.4142135f
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
index 4add92475..863d5b32a 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
@@ -71,26 +71,6 @@ class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
- /** An interface to provide text to use in the popup when fast-scrolling. */
- interface PopupProvider {
- /**
- * Get text to use in the popup at the specified position.
- * @param pos The position in the list.
- * @return A [String] to use in the popup. Null if there is no applicable text for the popup
- * at [pos].
- */
- fun getPopup(pos: Int): String?
- }
-
- /** A listener for fast scroller interactions. */
- interface Listener {
- /**
- * Called when the fast scrolling state changes.
- * @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
- */
- fun onFastScrollingChanged(isFastScrolling: Boolean)
- }
-
// Thumb
private val thumbView =
View(context).apply {
@@ -524,7 +504,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
else -> 0
}
- companion object {
- private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
+ /** An interface to provide text to use in the popup when fast-scrolling. */
+ interface PopupProvider {
+ /**
+ * Get text to use in the popup at the specified position.
+ * @param pos The position in the list.
+ * @return A [String] to use in the popup. Null if there is no applicable text for the popup
+ * at [pos].
+ */
+ fun getPopup(pos: Int): String?
+ }
+
+ /** A listener for fast scroller interactions. */
+ interface Listener {
+ /**
+ * Called when the fast scrolling state changes.
+ * @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
+ */
+ fun onFastScrollingChanged(isFastScrolling: Boolean)
+ }
+
+ private companion object {
+ const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index f37309f84..ad91fdade 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -94,8 +94,8 @@ class AlbumListFragment :
is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
- // Year -> Use Full Year
- is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
+ // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
+ is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
@@ -152,7 +152,7 @@ class AlbumListFragment :
get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- AlbumViewHolder.new(parent)
+ AlbumViewHolder.from(parent)
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index 29fecfd17..2dd51edf6 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -127,7 +127,7 @@ class ArtistListFragment :
get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- ArtistViewHolder.new(parent)
+ ArtistViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index 1019a85eb..fa3484d90 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -126,7 +126,7 @@ class GenreListFragment :
get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- GenreViewHolder.new(parent)
+ GenreViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index c040761fc..0b85bcb5f 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -103,7 +103,7 @@ class SongListFragment :
song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
- is Sort.Mode.ByDate -> song.album.date?.resolveDate(requireContext())
+ is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
@@ -166,7 +166,7 @@ class SongListFragment :
get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- SongViewHolder.new(parent)
+ SongViewHolder.from(parent)
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
index 76f7cf95d..1e74ad396 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
@@ -17,6 +17,7 @@
package org.oxycblt.auxio.home.tabs
+import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE
@@ -25,7 +26,7 @@ import org.oxycblt.auxio.util.logE
* @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class Tab(open val mode: MusicMode) {
+sealed class Tab(open val mode: MusicMode) : Item {
/**
* A visible tab. This will be visible in the home and tab configuration views.
* @param mode The type of list in the home view this instance corresponds to.
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
index 10a459d5c..756f8fea1 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
@@ -18,27 +18,28 @@
package org.oxycblt.auxio.home.tabs
import android.annotation.SuppressLint
-import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
+import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
- * @param listener A [Listener] for tab interactions.
+ * @param listener A [EditableListListener] for tab interactions.
*/
-class TabAdapter(private val listener: Listener) : RecyclerView.Adapter() {
+class TabAdapter(private val listener: EditableListListener) :
+ RecyclerView.Adapter() {
/** The current array of [Tab]s. */
var tabs = arrayOf()
private set
override fun getItemCount() = tabs.size
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.from(parent)
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position], listener)
}
@@ -75,30 +76,13 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter Invisible and vice versa).
- * @param tabMode The [MusicMode] of the tab clicked.
- */
- fun onToggleVisibility(tabMode: MusicMode)
-
- /**
- * Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
- * drag should be started.
- * @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
- */
- fun onPickUp(viewHolder: RecyclerView.ViewHolder)
- }
-
- companion object {
- private val PAYLOAD_TAB_CHANGED = Any()
+ private companion object {
+ val PAYLOAD_TAB_CHANGED = Any()
}
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Tab]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
@@ -106,12 +90,11 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
/**
* Bind new data to this instance.
* @param tab The new [Tab] to bind.
- * @param listener A [TabAdapter.Listener] to bind interactions to.
+ * @param listener A [EditableListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
- fun bind(tab: Tab, listener: TabAdapter.Listener) {
- binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
-
+ fun bind(tab: Tab, listener: EditableListListener) {
+ listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode
setText(
@@ -126,15 +109,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
// the tab data since they are in the same data structure (Tab)
isChecked = tab is Tab.Visible
}
-
- // Set up the drag handle to start a drag whenever it is touched.
- binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
- binding.tabDragHandle.performClick()
- if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
- listener.onPickUp(this)
- true
- } else false
- }
}
companion object {
@@ -143,6 +117,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
+ fun from(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
index 0bb712767..6c9706762 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
@@ -25,23 +25,19 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
-import org.oxycblt.auxio.music.MusicMode
+import org.oxycblt.auxio.list.EditableListListener
+import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
-class TabCustomizeDialog : ViewBindingDialogFragment(), TabAdapter.Listener {
- private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
-
+class TabCustomizeDialog : ViewBindingDialogFragment(), EditableListListener {
private val tabAdapter = TabAdapter(this)
- private val touchHelper: ItemTouchHelper by lifecycleObject {
- ItemTouchHelper(TabDragCallback(tabAdapter))
- }
+ private var touchHelper: ItemTouchHelper? = null
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@@ -50,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), TabAd
.setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes")
- settings.libTabs = tabAdapter.tabs
+ Settings(requireContext()).libTabs = tabAdapter.tabs
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
- var tabs = settings.libTabs
+ var tabs = Settings(requireContext()).libTabs
// Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
@@ -69,7 +65,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), TabAd
tabAdapter.submitTabs(tabs)
binding.tabRecycler.apply {
adapter = tabAdapter
- touchHelper.attachToRecyclerView(this)
+ touchHelper =
+ ItemTouchHelper(TabDragCallback(tabAdapter)).also { it.attachToRecyclerView(this) }
}
}
@@ -84,12 +81,11 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), TabAd
binding.tabRecycler.adapter = null
}
- override fun onToggleVisibility(tabMode: MusicMode) {
- logD("Toggling tab $tabMode")
-
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
+ check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
// We will need the exact index of the tab to update on in order to
// notify the adapter of the change.
- val index = tabAdapter.tabs.indexOfFirst { it.mode == tabMode }
+ val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
val tab = tabAdapter.tabs[index]
tabAdapter.setTab(
index,
@@ -105,10 +101,10 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), TabAd
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
- touchHelper.startDrag(viewHolder)
+ requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
- companion object {
- private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
+ private companion object {
+ const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
index 5b9e814a9..68adab079 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
@@ -65,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float
init {
- // Obtain some StyledImageView attributes to use later when theming the cusotm view.
+ // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
// Keep track of our corner radius so that we can apply the same attributes to the custom
@@ -107,7 +107,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Playback indicator should sit above the inner StyledImageView and custom view/
addView(playbackIndicatorView)
- // Selction indicator should never be obscured, so place it at the top.
+ // Selection indicator should never be obscured, so place it at the top.
addView(
selectionIndicatorView,
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
index 6b703e37f..31a4bda55 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
@@ -177,6 +177,8 @@ object Covers {
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
// Eliminate any chance that this blocking call might mess up the loading process
- return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
+ return withContext(Dispatchers.IO) {
+ context.contentResolver.openInputStream(album.coverUri)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
index f8071816e..250992d04 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
@@ -22,6 +22,7 @@ import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.activityViewModels
+import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
@@ -53,7 +54,7 @@ abstract class ListFragment : SelectionFragment(), Selecta
*/
abstract fun onRealClick(music: Music)
- override fun onClick(item: Item) {
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
if (selectionModel.selected.value.isNotEmpty()) {
// Map clicking an item to selecting an item when items are already selected.
diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
index 926fb6904..c8f9ebb6d 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
@@ -17,8 +17,8 @@
package org.oxycblt.auxio.list
+import android.view.MotionEvent
import android.view.View
-import android.widget.Button
import androidx.recyclerview.widget.RecyclerView
/**
@@ -26,13 +26,63 @@ import androidx.recyclerview.widget.RecyclerView
* @author Alexander Capehart (OxygenCobalt)
*/
interface ClickableListListener {
- // TODO: Supply a ViewHolder on clicks
- // (allows editable lists to be standardized into a listener.)
/**
* Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked.
+ * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
*/
- fun onClick(item: Item)
+ fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder)
+
+ /**
+ * Binds this instance to a list item.
+ * @param item The [Item] that this list entry is bound to.
+ * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
+ * @param bodyView The [View] containing the main body of the list item. Any click events on
+ * this [View] are routed to the listener. Defaults to the root view.
+ */
+ fun bind(
+ item: Item,
+ viewHolder: RecyclerView.ViewHolder,
+ bodyView: View = viewHolder.itemView
+ ) {
+ bodyView.setOnClickListener { onClick(item, viewHolder) }
+ }
+}
+
+/**
+ * An extension of [ClickableListListener] that enables list editing functionality.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+interface EditableListListener : ClickableListListener {
+ /**
+ * Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
+ * @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
+ */
+ fun onPickUp(viewHolder: RecyclerView.ViewHolder)
+
+ /**
+ * Binds this instance to a list item.
+ * @param item The [Item] that this list entry is bound to.
+ * @param viewHolder The [RecyclerView.ViewHolder] to bind.
+ * @param bodyView The [View] containing the main body of the list item. Any click events on
+ * this [View] are routed to the listener. Defaults to the root view.
+ * @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
+ */
+ fun bind(
+ item: Item,
+ viewHolder: RecyclerView.ViewHolder,
+ bodyView: View = viewHolder.itemView,
+ dragHandle: View
+ ) {
+ bind(item, viewHolder, bodyView)
+ dragHandle.setOnTouchListener { _, motionEvent ->
+ dragHandle.performClick()
+ if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
+ onPickUp(viewHolder)
+ true
+ } else false
+ }
+ }
}
/**
@@ -55,19 +105,23 @@ interface SelectableListListener : ClickableListListener {
/**
* Binds this instance to a list item.
- * @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param item The [Item] that this list entry is bound to.
- * @param menuButton A [Button] that opens a menu.
+ * @param viewHolder The [RecyclerView.ViewHolder] to bind.
+ * @param bodyView The [View] containing the main body of the list item. Any click events on
+ * this [View] are routed to the listener. Defaults to the root view.
+ * @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
*/
- fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
- viewHolder.itemView.apply {
- // Map clicks to the click listener.
- setOnClickListener { onClick(item) }
- // Map long clicks to the selection listener.
- setOnLongClickListener {
- onSelect(item)
- true
- }
+ fun bind(
+ item: Item,
+ viewHolder: RecyclerView.ViewHolder,
+ bodyView: View = viewHolder.itemView,
+ menuButton: View
+ ) {
+ bind(item, viewHolder, bodyView)
+ // Map long clicks to the selection listener.
+ bodyView.setOnLongClickListener {
+ onSelect(item)
+ true
}
// Map the menu button to the menu opening listener.
menuButton.setOnClickListener { onOpenMenu(item, it) }
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
index 17b016575..ceb1fbf0b 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt
@@ -115,7 +115,7 @@ abstract class PlayingIndicatorAdapter : RecyclerV
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
}
- companion object {
- private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
+ private companion object {
+ val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt
index 29ecc2582..64036c7cd 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt
@@ -77,7 +77,7 @@ abstract class SelectionIndicatorAdapter :
abstract fun updateSelectionIndicator(isSelected: Boolean)
}
- companion object {
- private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
+ private companion object {
+ val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
index 9f3d07805..15628c41c 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
@@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
- * A [RecyclerView.ViewHolder] that displays a [Song]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
@@ -46,7 +46,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
- listener.bind(this, song, binding.songMenu)
+ listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(binding.context)
@@ -70,7 +70,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
+ fun from(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -82,7 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Album]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Album]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
@@ -93,7 +93,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
- listener.bind(this, album, binding.parentMenu)
+ listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text = album.resolveArtistContents(binding.context)
@@ -117,7 +117,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
+ fun from(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -131,7 +131,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Artist]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Artist]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
@@ -142,7 +142,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: SelectableListListener) {
- listener.bind(this, artist, binding.parentMenu)
+ listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
binding.parentInfo.text =
@@ -175,7 +175,8 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
+ fun from(parent: View) =
+ ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -189,7 +190,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Genre]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Genre]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
@@ -200,7 +201,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: SelectableListListener) {
- listener.bind(this, genre, binding.parentMenu)
+ listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
binding.parentInfo.text =
@@ -228,7 +229,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
+ fun from(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -240,7 +241,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Header]. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
@@ -262,7 +263,8 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
+ fun from(parent: View) =
+ HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
index ae6b0bb60..754e8ca08 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
@@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.*
* A [ViewModel] that manages the current selection.
* @author Alexander Capehart (OxygenCobalt)
*/
-class SelectionViewModel : ViewModel(), MusicStore.Callback {
+class SelectionViewModel : ViewModel(), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val _selected = MutableStateFlow(listOf())
@@ -35,7 +35,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
get() = _selected
init {
- musicStore.addCallback(this)
+ musicStore.addListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -58,7 +58,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
override fun onCleared() {
super.onCleared()
- musicStore.removeCallback(this)
+ musicStore.removeListener(this)
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt
new file mode 100644
index 000000000..f6f7b1221
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music
+
+import android.content.Context
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import kotlin.math.max
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.util.inRangeOrNull
+import org.oxycblt.auxio.util.nonZeroOrNull
+
+/**
+ * An ISO-8601/RFC 3339 Date.
+ *
+ * This class only encodes the timestamp spec and it's conversion to a human-readable date, without
+ * any other time management or validation. In general, this should only be used for display. Use
+ * [from] to create an instance.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class Date private constructor(private val tokens: List) : Comparable {
+ private val year = tokens[0]
+ private val month = tokens.getOrNull(1)
+ private val day = tokens.getOrNull(2)
+ private val hour = tokens.getOrNull(3)
+ private val minute = tokens.getOrNull(4)
+ private val second = tokens.getOrNull(5)
+
+ /**
+ * Resolve this instance into a human-readable date.
+ * @param context [Context] required to get human-readable names.
+ * @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan
+ * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
+ * be properly localized.
+ */
+ fun resolveDate(context: Context): String {
+ if (month != null) {
+ // Parse a date format from an ISO-ish format
+ val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
+ format.applyPattern("yyyy-MM")
+ val date =
+ try {
+ format.parse("$year-$month")
+ } catch (e: ParseException) {
+ null
+ }
+
+ if (date != null) {
+ // Reformat as a readable month and year
+ format.applyPattern("MMM yyyy")
+ return format.format(date)
+ }
+ }
+
+ // Unable to create fine-grained date, just format as a year.
+ return context.getString(R.string.fmt_number, year)
+ }
+
+ override fun hashCode() = tokens.hashCode()
+
+ override fun equals(other: Any?) = other is Date && tokens == other.tokens
+
+ override fun compareTo(other: Date): Int {
+ for (i in 0 until max(tokens.size, other.tokens.size)) {
+ val ai = tokens.getOrNull(i)
+ val bi = other.tokens.getOrNull(i)
+ when {
+ ai != null && bi != null -> {
+ val result = ai.compareTo(bi)
+ if (result != 0) {
+ return result
+ }
+ }
+ ai == null && bi != null -> return -1 // a < b
+ ai == null && bi == null -> return 0 // a = b
+ else -> return 1 // a < b
+ }
+ }
+
+ return 0
+ }
+
+ override fun toString() = StringBuilder().appendDate().toString()
+
+ private fun StringBuilder.appendDate(): StringBuilder {
+ // Construct an ISO-8601 date, dropping precision that doesn't exist.
+ append(year.toStringFixed(4))
+ append("-${(month ?: return this).toStringFixed(2)}")
+ append("-${(day ?: return this).toStringFixed(2)}")
+ append("T${(hour ?: return this).toStringFixed(2)}")
+ append(":${(minute ?: return this.append('Z')).toStringFixed(2)}")
+ append(":${(second ?: return this.append('Z')).toStringFixed(2)}")
+ return this.append('Z')
+ }
+
+ private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len)
+
+ /**
+ * A range of [Date]s. This is used in contexts where the [Date] of an item is derived from
+ * several sub-items and thus can have a "range" of release dates. Use [from] to create an
+ * instance.
+ * @author Alexander Capehart
+ */
+ class Range
+ private constructor(
+ /** The earliest [Date] in the range. */
+ val min: Date,
+ /** the latest [Date] in the range. May be the same as [min]. ] */
+ val max: Date
+ ) : Comparable {
+
+ /**
+ * Resolve this instance into a human-readable date range.
+ * @param context [Context] required to get human-readable names.
+ * @return If the date has a maximum value, then a `min - max` formatted string will be
+ * returned with the formatted [Date]s of the minimum and maximum dates respectively.
+ * Otherwise, the formatted name of the minimum [Date] will be returned.
+ */
+ fun resolveDate(context: Context) =
+ if (min != max) {
+ context.getString(
+ R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
+ } else {
+ min.resolveDate(context)
+ }
+
+ override fun equals(other: Any?) =
+ other is Range && min == other.min && max == other.max
+
+ override fun hashCode() = 31 * max.hashCode() + min.hashCode()
+
+ override fun compareTo(other: Range) = min.compareTo(other.min)
+
+ companion object {
+ /**
+ * Create a [Range] from the given list of [Date]s.
+ * @param dates The [Date]s to use.
+ * @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
+ * null is returned.
+ */
+ fun from(dates: List): Range? {
+ if (dates.isEmpty()) {
+ // Nothing to do.
+ return null
+ }
+ // Simultaneously find the minimum and maximum values in the given range.
+ // If this list has only one item, then that one date is the minimum and maximum.
+ var min = dates.first()
+ var max = min
+ for (i in 1..dates.lastIndex) {
+ if (dates[i] < min) {
+ min = dates[i]
+ }
+ if (dates[i] > max) {
+ max = dates[i]
+ }
+ }
+ return Range(min, max)
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
+ * https://github.com/quodlibet/mutagen
+ */
+ private val ISO8601_REGEX =
+ Regex(
+ """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
+
+ /**
+ * Create a [Date] from a year component.
+ * @param year The year component.
+ * @return A new [Date] of the given component, or null if the component is invalid.
+ */
+ fun from(year: Int) = fromTokens(listOf(year))
+
+ /**
+ * Create a [Date] from a date component.
+ * @param year The year component.
+ * @param month The month component.
+ * @param day The day component.
+ * @return A new [Date] consisting of the given components. May have reduced precision if
+ * the components were partially invalid, and will be null if all components are invalid.
+ */
+ fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
+
+ /**
+ * Create [Date] from a datetime component.
+ * @param year The year component.
+ * @param month The month component.
+ * @param day The day component.
+ * @param hour The hour component
+ * @return A new [Date] consisting of the given components. May have reduced precision if
+ * the components were partially invalid, and will be null if all components are invalid.
+ */
+ fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
+ fromTokens(listOf(year, month, day, hour, minute))
+
+ /**
+ * Create a [Date] from a [String] timestamp.
+ * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision.
+ * @return A new [Date] consisting of the given components. May have reduced precision if
+ * the components were partially invalid, and will be null if all components are invalid or
+ * if the timestamp is invalid.
+ */
+ fun from(timestamp: String): Date? {
+ val tokens =
+ // Match the input with the timestamp regex
+ (ISO8601_REGEX.matchEntire(timestamp) ?: return null)
+ .groupValues
+ // Filter to the specific tokens we want and convert them to integer tokens.
+ .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
+ return fromTokens(tokens)
+ }
+
+ /**
+ * Create a [Date] from the given non-validated tokens.
+ * @param tokens The tokens to use for each date component, in order of precision.
+ * @return A new [Date] consisting of the given components. May have reduced precision if
+ * the components were partially invalid, and will be null if all components are invalid.
+ */
+ private fun fromTokens(tokens: List): Date? {
+ val validated = mutableListOf()
+ validateTokens(tokens, validated)
+ if (validated.isEmpty()) {
+ // No token was valid, return null.
+ return null
+ }
+ return Date(validated)
+ }
+
+ /**
+ * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
+ * as soon as an invalid token is found.
+ * @param src The input tokens to validate.
+ * @param dst The destination list to add valid tokens to.
+ */
+ private fun validateTokens(src: List, dst: MutableList) {
+ dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
+ dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
+ dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
+ dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
+ dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
+ dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index aa424003c..095bae072 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -24,20 +24,16 @@ import android.os.Parcelable
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
-import java.text.ParseException
-import java.text.SimpleDateFormat
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
-import org.oxycblt.auxio.music.extractor.parseId3GenreNames
-import org.oxycblt.auxio.music.extractor.parseMultiValue
-import org.oxycblt.auxio.music.extractor.toUuidOrNull
-import org.oxycblt.auxio.music.storage.*
+import org.oxycblt.auxio.music.filesystem.*
+import org.oxycblt.auxio.music.parsing.parseId3GenreNames
+import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.settings.Settings
-import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -114,6 +110,27 @@ sealed class Music : Item {
return COLLATOR.getCollationKey(sortName)
}
+ /**
+ * Join a list of [Music]'s resolved names into a string in a localized manner, using
+ * [R.string.fmt_list].
+ * @param context [Context] required to obtain localized formatting.
+ * @param values The list of [Music] to format.
+ * @return A single string consisting of the values delimited by a localized separator.
+ */
+ protected fun resolveNames(context: Context, values: List): String {
+ if (values.isEmpty()) {
+ // Nothing to do.
+ return ""
+ }
+
+ var joined = values.first().resolveName(context)
+ for (i in 1..values.lastIndex) {
+ // Chain all previous values with the next value in the list with another delimiter.
+ joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
+ }
+ return joined
+ }
+
// Note: We solely use the UID in comparisons so that certain items that differ in all
// but UID are treated differently.
@@ -262,9 +279,9 @@ sealed class Music : Item {
}
}
- companion object {
+ private companion object {
/** Cached collator instance re-used with [makeCollationKeyImpl]. */
- private val COLLATOR = Collator.getInstance().apply { strength = Collator.PRIMARY }
+ val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
}
}
@@ -399,9 +416,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter.
*/
- fun resolveArtistContents(context: Context) =
- // TODO Internationalize the list
- artists.joinToString { it.resolveName(context) }
+ fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
@@ -433,7 +448,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
* Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
+ fun resolveGenreContents(context: Context) = resolveNames(context, genres)
// --- INTERNAL FIELDS ---
@@ -504,7 +519,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
- // TODO: Make sure this works for artists only derived from album artists.
val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
@@ -610,11 +624,8 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName
- /**
- * The earliest [Date] this album was released. Will be null if no valid date was present in the
- * metadata of any [Song]
- */
- val date: Date? // TODO: Date ranges?
+ /** The [Date.Range] that [Song]s in the [Album] were released. */
+ val dates = Date.Range.from(songs.mapNotNull { it.date })
/**
* The [Type] of this album, signifying the type of release it actually is. Defaults to
@@ -634,31 +645,18 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
val dateAdded: Long
init {
- var earliestDate: Date? = null
var totalDuration: Long = 0
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in songs) {
song._link(this)
-
- if (song.date != null) {
- // Since we can't really assign a maximum value for dates, we instead
- // just check if the current earliest date doesn't exist and fill it
- // in with the current song if that's the case.
- if (earliestDate == null || song.date < earliestDate) {
- earliestDate = song.date
- }
- }
-
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
-
totalDuration += song.durationMs
}
- date = earliestDate
durationMs = totalDuration
dateAdded = earliestDateAdded
}
@@ -676,7 +674,7 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent(
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveArtistContents(context: Context) = artists.joinToString { it.resolveName(context) }
+ fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
@@ -1043,7 +1041,7 @@ class Artist constructor(private val raw: Raw, songAlbums: List) : MusicP
* Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
- fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
+ fun resolveGenreContents(context: Context) = resolveNames(context, genres)
/**
* Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
@@ -1212,181 +1210,20 @@ class Genre constructor(private val raw: Raw, override val songs: List) :
}
}
-/**
- * An ISO-8601/RFC 3339 Date.
- *
- * This class only encodes the timestamp spec and it's conversion to a human-readable date, without
- * any other time management or validation. In general, this should only be used for display.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class Date private constructor(private val tokens: List) : Comparable {
- private val year = tokens[0]
- private val month = tokens.getOrNull(1)
- private val day = tokens.getOrNull(2)
- private val hour = tokens.getOrNull(3)
- private val minute = tokens.getOrNull(4)
- private val second = tokens.getOrNull(5)
-
- /**
- * Resolve this instance into a human-readable date.
- * @param context [Context] required to get human-readable names.
- * @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan
- * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
- * be properly localized.
- */
- fun resolveDate(context: Context): String {
- if (month != null) {
- // Parse a date format from an ISO-ish format
- val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
- format.applyPattern("yyyy-MM")
- val date =
- try {
- format.parse("$year-$month")
- } catch (e: ParseException) {
- null
- }
-
- if (date != null) {
- // Reformat as a readable month and year
- format.applyPattern("MMM yyyy")
- return format.format(date)
- }
- }
-
- // Unable to create fine-grained date, just format as a year.
- return context.getString(R.string.fmt_number, year)
- }
-
- override fun hashCode() = tokens.hashCode()
-
- override fun equals(other: Any?) = other is Date && tokens == other.tokens
-
- override fun compareTo(other: Date): Int {
- for (i in 0 until max(tokens.size, other.tokens.size)) {
- val ai = tokens.getOrNull(i)
- val bi = other.tokens.getOrNull(i)
- when {
- ai != null && bi != null -> {
- val result = ai.compareTo(bi)
- if (result != 0) {
- return result
- }
- }
- ai == null && bi != null -> return -1 // a < b
- ai == null && bi == null -> return 0 // a = b
- else -> return 1 // a < b
- }
- }
-
- return 0
- }
-
- override fun toString() = StringBuilder().appendDate().toString()
-
- private fun StringBuilder.appendDate(): StringBuilder {
- // Construct an ISO-8601 date, dropping precision that doesn't exist.
- append(year.toStringFixed(4))
- append("-${(month ?: return this).toStringFixed(2)}")
- append("-${(day ?: return this).toStringFixed(2)}")
- append("T${(hour ?: return this).toStringFixed(2)}")
- append(":${(minute ?: return this.append('Z')).toStringFixed(2)}")
- append(":${(second ?: return this.append('Z')).toStringFixed(2)}")
- return this.append('Z')
- }
-
- private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len)
-
- companion object {
- /**
- * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
- * https://github.com/quodlibet/mutagen
- */
- private val ISO8601_REGEX =
- Regex(
- """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
-
- /**
- * Create a [Date] from a year component.
- * @param year The year component.
- * @return A new [Date] of the given component, or null if the component is invalid.
- */
- fun from(year: Int) = fromTokens(listOf(year))
-
- /**
- * Create a [Date] from a date component.
- * @param year The year component.
- * @param month The month component.
- * @param day The day component.
- * @return A new [Date] consisting of the given components. May have reduced precision if
- * the components were partially invalid, and will be null if all components are invalid.
- */
- fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
-
- /**
- * Create [Date] from a datetime component.
- * @param year The year component.
- * @param month The month component.
- * @param day The day component.
- * @param hour The hour component
- * @return A new [Date] consisting of the given components. May have reduced precision if
- * the components were partially invalid, and will be null if all components are invalid.
- */
- fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
- fromTokens(listOf(year, month, day, hour, minute))
-
- /**
- * Create a [Date] from a [String] timestamp.
- * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision.
- * @return A new [Date] consisting of the given components. May have reduced precision if
- * the components were partially invalid, and will be null if all components are invalid or
- * if the timestamp is invalid.
- */
- fun from(timestamp: String): Date? {
- val tokens =
- // Match the input with the timestamp regex
- (ISO8601_REGEX.matchEntire(timestamp) ?: return null)
- .groupValues
- // Filter to the specific tokens we want and convert them to integer tokens.
- .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
- return fromTokens(tokens)
- }
-
- /**
- * Create a [Date] from the given non-validated tokens.
- * @param tokens The tokens to use for each date component, in order of precision.
- * @return A new [Date] consisting of the given components. May have reduced precision if
- * the components were partially invalid, and will be null if all components are invalid.
- */
- private fun fromTokens(tokens: List): Date? {
- val validated = mutableListOf()
- validateTokens(tokens, validated)
- if (validated.isEmpty()) {
- // No token was valid, return null.
- return null
- }
- return Date(validated)
- }
-
- /**
- * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
- * as soon as an invalid token is found.
- * @param src The input tokens to validate.
- * @param dst The destination list to add valid tokens to.
- */
- private fun validateTokens(src: List, dst: MutableList) {
- dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
- dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
- dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
- dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
- dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
- dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
- }
- }
-}
-
// --- MUSIC UID CREATION UTILITIES ---
+/**
+ * Convert a [String] to a [UUID].
+ * @return A [UUID] converted from the [String] value, or null if the value was not valid.
+ * @see UUID.fromString
+ */
+fun String.toUuidOrNull(): UUID? =
+ try {
+ UUID.fromString(this)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+
/**
* Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
index 185cbb6ad..aa40f8ec5 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
@@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
-import org.oxycblt.auxio.music.storage.contentResolverSafe
-import org.oxycblt.auxio.music.storage.useQuery
+import org.oxycblt.auxio.music.filesystem.contentResolverSafe
+import org.oxycblt.auxio.music.filesystem.useQuery
/**
* A repository granting access to the music library..
@@ -33,42 +33,43 @@ import org.oxycblt.auxio.music.storage.useQuery
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicStore private constructor() {
- private val callbacks = mutableListOf()
+ private val listeners = mutableListOf()
/**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
* can change, so it's highly recommended to not access this directly and instead rely on
- * [Callback].
+ * [Listener].
*/
+ @Volatile
var library: Library? = null
set(value) {
field = value
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onLibraryChanged(library)
}
}
/**
- * Add a [Callback] to this instance. This can be used to receive changes in the music library.
- * Will invoke all [Callback] methods to initialize the instance with the current state.
- * @param callback The [Callback] to add.
- * @see Callback
+ * Add a [Listener] to this instance. This can be used to receive changes in the music library.
+ * Will invoke all [Listener] methods to initialize the instance with the current state.
+ * @param listener The [Listener] to add.
+ * @see Listener
*/
@Synchronized
- fun addCallback(callback: Callback) {
- callback.onLibraryChanged(library)
- callbacks.add(callback)
+ fun addListener(listener: Listener) {
+ listener.onLibraryChanged(library)
+ listeners.add(listener)
}
/**
- * Remove a [Callback] from this instance, preventing it from recieving any further updates.
- * @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
+ * Remove a [Listener] from this instance, preventing it from recieving any further updates.
+ * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
- * @see Callback
+ * @see Listener
*/
@Synchronized
- fun removeCallback(callback: Callback) {
- callbacks.remove(callback)
+ fun removeListener(listener: Listener) {
+ listeners.remove(listener)
}
/**
@@ -167,7 +168,7 @@ class MusicStore private constructor() {
}
/** A listener for changes in the music library. */
- interface Callback {
+ interface Listener {
/**
* Called when the current [Library] has changed.
* @param library The new [Library], or null if no [Library] has been loaded yet.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index c115d080b..8df230e71 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.system.Indexer
* A [ViewModel] providing data specific to the music loading process.
* @author Alexander Capehart (OxygenCobalt)
*/
-class MusicViewModel : ViewModel(), Indexer.Callback {
+class MusicViewModel : ViewModel(), Indexer.Listener {
private val indexer = Indexer.getInstance()
private val _indexerState = MutableStateFlow(null)
@@ -39,18 +39,18 @@ class MusicViewModel : ViewModel(), Indexer.Callback {
get() = _statistics
init {
- indexer.registerCallback(this)
+ indexer.registerListener(this)
}
override fun onCleared() {
- indexer.unregisterCallback(this)
+ indexer.unregisterListener(this)
}
override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = state
- if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
+ if (state is Indexer.State.Complete) {
// New state is a completed library, update the statistics values.
- val library = state.response.library
+ val library = state.result.getOrNull() ?: return
_statistics.value =
Statistics(
library.songs.size,
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
index 7ce2248bd..5c8fa818f 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt
@@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator =
MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
- compareByDescending(NullableComparator.DATE) { it.album.date },
+ compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
@@ -241,14 +241,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator =
MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
- compareByDescending(NullableComparator.DATE) { it.date },
+ compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
/**
* Sort by the [Date] of an item. Only available for [Song] and [Album].
* @see Song.date
- * @see Album.date
+ * @see Album.dates
*/
object ByDate : Mode() {
override val intCode: Int
@@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
+ compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
@@ -267,7 +267,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
+ compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
@@ -366,7 +366,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
/**
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
* @see Song.dateAdded
- * @see Album.date
+ * @see Album.dates
*/
object ByDateAdded : Mode() {
override val intCode: Int
@@ -543,8 +543,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val INT = NullableComparator()
/** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator()
- /** A re-usable instance configured for [Date]s. */
- val DATE = NullableComparator()
+ /** A re-usable instance configured for [Date.Range]s. */
+ val DATE_RANGE = NullableComparator()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
index e9159a0db..10dc6ed72 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
@@ -23,7 +23,10 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
+import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.parsing.correctWhitespace
+import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.util.*
/**
@@ -278,7 +281,7 @@ private class CacheDatabase(context: Context) :
raw.track = cursor.getIntOrNull(trackIndex)
raw.disc = cursor.getIntOrNull(discIndex)
- raw.date = cursor.getStringOrNull(dateIndex)?.parseTimestamp()
+ raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex)
@@ -387,8 +390,7 @@ private class CacheDatabase(context: Context) :
* @return A list of strings corresponding to the delimited values present within the original
* string. Escaped delimiters are converted back into their normal forms.
*/
- private fun String.parseSQLMultiValue() =
- splitEscaped { it == ';' }
+ private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
/** Defines the columns used in this database. */
private object Columns {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
index 6b56f8c70..72177d409 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
@@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
enum class ExtractionResult {
/** A raw song was successfully extracted from the cache. */
CACHED,
-
/** A raw song was successfully extracted from parsing it's file. */
PARSED,
-
/** A raw song could not be parsed. */
NONE
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
index 8ec2adadd..cc65b8b7f 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
@@ -27,17 +27,20 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
+import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.storage.Directory
-import org.oxycblt.auxio.music.storage.contentResolverSafe
-import org.oxycblt.auxio.music.storage.directoryCompat
-import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
-import org.oxycblt.auxio.music.storage.safeQuery
-import org.oxycblt.auxio.music.storage.storageVolumesCompat
-import org.oxycblt.auxio.music.storage.useQuery
+import org.oxycblt.auxio.music.filesystem.Directory
+import org.oxycblt.auxio.music.filesystem.contentResolverSafe
+import org.oxycblt.auxio.music.filesystem.directoryCompat
+import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
+import org.oxycblt.auxio.music.filesystem.safeQuery
+import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
+import org.oxycblt.auxio.music.filesystem.useQuery
+import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.nonZeroOrNull
/**
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the
@@ -302,7 +305,7 @@ abstract class MediaStoreExtractor(
// MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
- raw.date = cursor.getIntOrNull(yearIndex)?.toDate()
+ raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to
@@ -322,12 +325,12 @@ abstract class MediaStoreExtractor(
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
}
- companion object {
+ private companion object {
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
*/
- private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
+ const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
/**
* The album artist of a song. This column has existed since at least API 21, but until API
@@ -335,13 +338,13 @@ abstract class MediaStoreExtractor(
* versions that Auxio supports.
*/
@Suppress("InlinedApi")
- private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
+ const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* The external volume. This naming has existed since API 21, but no constant existed for it
* until API 29. This will work on all versions that Auxio supports.
*/
- @Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
+ @Suppress("InlinedApi") const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
}
}
@@ -561,7 +564,24 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it.
- cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
- cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
+ cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
+ cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it }
}
}
+
+/**
+ * Unpack the track number from a combined track + disc [Int] field. These fields appear within
+ * MediaStore's TRACK column, and combine the track and disc value into a single field where the
+ * disc number is the 4th+ digit.
+ * @return The track number extracted from the combined integer value, or null if the value was
+ * zero.
+ */
+private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
+
+/**
+ * Unpack the disc number from a combined track + disc [Int] field. These fields appear within
+ * MediaStore's TRACK column, and combine the track and disc value into a single field where the
+ * disc number is the 4th+ digit.
+ * @return The disc number extracted from the combined integer field, or null if the value was zero.
+ */
+private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
index 6603850e4..8aad9fab7 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt
@@ -21,12 +21,10 @@ import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
-import com.google.android.exoplayer2.metadata.Metadata
-import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
-import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.storage.toAudioUri
+import org.oxycblt.auxio.music.filesystem.toAudioUri
+import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@@ -116,8 +114,8 @@ class MetadataExtractor(
}
}
- companion object {
- private const val TASK_CAPACITY = 8
+ private companion object {
+ const val TASK_CAPACITY = 8
}
}
@@ -128,7 +126,6 @@ class MetadataExtractor(
* @author Alexander Capehart (OxygenCobalt)
*/
class Task(context: Context, private val raw: Song.Raw) {
- // TODO: Unify with MetadataExtractor
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
@@ -144,6 +141,7 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
fun get(): Song.Raw? {
if (!future.isDone) {
+ // Not done yet, nothing to do.
return null
}
@@ -162,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
val metadata = format.metadata
if (metadata != null) {
- populateWithMetadata(metadata)
+ val tags = Tags(metadata)
+ populateWithId3v2(tags.id3v2)
+ populateWithVorbis(tags.vorbis)
} else {
logD("No metadata could be extracted for ${raw.name}")
}
@@ -170,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
return raw
}
- /**
- * Complete this instance's [Song.Raw] with the newly extracted [Metadata].
- * @param metadata The [Metadata] to complete the [Song.Raw] with.
- */
- private fun populateWithMetadata(metadata: Metadata) {
- val id3v2Tags = mutableMapOf>()
- val vorbisTags = mutableMapOf>()
-
- // ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
- // of audio formats. Load both of these types of tags into separate maps, letting the
- // "source of truth" be the last of a particular tag in a file.
- for (i in 0 until metadata.length()) {
- when (val tag = metadata[i]) {
- is TextInformationFrame -> {
- // Map TXXX frames differently so we can specifically index by their
- // descriptions.
- val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
- val values = tag.values.map { it.sanitize() }.filter { it.isNotEmpty() }
- if (values.isNotEmpty()) {
- id3v2Tags[id] = values
- }
- }
- is VorbisComment -> {
- // Vorbis comment keys can be in any case, make them uppercase for simplicity.
- val id = tag.key.sanitize().uppercase()
- val value = tag.value.sanitize()
- if (value.isNotEmpty()) {
- vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
- }
- }
- }
- }
-
- when {
- vorbisTags.isEmpty() -> populateWithId3v2(id3v2Tags)
- id3v2Tags.isEmpty() -> populateWithVorbis(vorbisTags)
- else -> {
- // Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
- // them both with priority given to vorbis.
- populateWithId3v2(id3v2Tags)
- populateWithVorbis(vorbisTags)
- }
- }
- }
-
/**
* Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
@@ -222,15 +177,15 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
private fun populateWithId3v2(textFrames: Map>) {
// Song
- textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
+ textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] }
textFrames["TIT2"]?.let { raw.name = it[0] }
textFrames["TSOT"]?.let { raw.sortName = it[0] }
// Track. Only parse out the track number and ignore the total tracks value.
- textFrames["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
+ textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it }
// Disc. Only parse out the disc number and ignore the total discs value.
- textFrames["TPOS"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
+ textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@@ -241,27 +196,27 @@ class Task(context: Context, private val raw: Song.Raw) {
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
- (textFrames["TDOR"]?.run { get(0).parseTimestamp() }
- ?: textFrames["TDRC"]?.run { get(0).parseTimestamp() }
- ?: textFrames["TDRL"]?.run { get(0).parseTimestamp() }
+ (textFrames["TDOR"]?.run { Date.from(first()) }
+ ?: textFrames["TDRC"]?.run { Date.from(first()) }
+ ?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { raw.date = it }
// Album
- textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
+ textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] }
textFrames["TALB"]?.let { raw.albumName = it[0] }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
- (textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
+ (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.albumTypes = it
}
// Artist
- textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
+ textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TPE1"]?.let { raw.artistNames = it }
textFrames["TSOP"]?.let { raw.artistSortNames = it }
// Album artist
- textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
+ textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
@@ -282,8 +237,8 @@ class Task(context: Context, private val raw: Song.Raw) {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
- textFrames["TORY"]?.run { get(0).toIntOrNull() }
- ?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
+ textFrames["TORY"]?.run { first().toIntOrNull() }
+ ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
@@ -317,17 +272,17 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
private fun populateWithVorbis(comments: Map>) {
// Song
- comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
- comments["TITLE"]?.let { raw.name = it[0] }
- comments["TITLESORT"]?.let { raw.sortName = it[0] }
+ comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
+ comments["title"]?.let { raw.name = it[0] }
+ comments["titlesort"]?.let { raw.sortName = it[0] }
// Track. The total tracks value is in a different comment, so we can just
// convert the entirety of this comment into a number.
- comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
+ comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it }
// Disc. The total discs value is in a different comment, so we can just
// convert the entirety of this comment into a number.
- comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
+ comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@@ -335,35 +290,28 @@ class Task(context: Context, private val raw: Song.Raw) {
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!)
- (comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
- ?: comments["DATE"]?.run { get(0).parseTimestamp() }
- ?: comments["YEAR"]?.run { get(0).parseYear() })
+ (comments["originaldate"]?.run { Date.from(first()) }
+ ?: comments["date"]?.run { Date.from(first()) }
+ ?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
?.let { raw.date = it }
// Album
- comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
- comments["ALBUM"]?.let { raw.albumName = it[0] }
- comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
- comments["RELEASETYPE"]?.let { raw.albumTypes = it }
+ comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
+ comments["album"]?.let { raw.albumName = it[0] }
+ comments["albumsort"]?.let { raw.albumSortName = it[0] }
+ comments["releasetype"]?.let { raw.albumTypes = it }
// Artist
- comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
- comments["ARTIST"]?.let { raw.artistNames = it }
- comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
+ comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
+ comments["artist"]?.let { raw.artistNames = it }
+ comments["artistsort"]?.let { raw.artistSortNames = it }
// Album artist
- comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
- comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
- comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
+ comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
+ comments["albumartist"]?.let { raw.albumArtistNames = it }
+ comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
// Genre
comments["GENRE"]?.let { raw.genreNames = it }
}
-
- /**
- * Copies and sanitizes a possibly native/non-UTF-8 string.
- * @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
- * the Unicode replacement byte sequence.
- */
- private fun String.sanitize() = String(encodeToByteArray())
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
new file mode 100644
index 000000000..03179a230
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.extractor
+
+import com.google.android.exoplayer2.metadata.Metadata
+import com.google.android.exoplayer2.metadata.id3.InternalFrame
+import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
+import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
+import org.oxycblt.auxio.music.parsing.correctWhitespace
+
+/**
+ * Processing wrapper for [Metadata] that allows access to more organized music tags.
+ * @param metadata The [Metadata] to wrap.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class Tags(metadata: Metadata) {
+ private val _id3v2 = mutableMapOf>()
+ /** The ID3v2 text identification frames found in the file. Can have more than one value. */
+ val id3v2: Map>
+ get() = _id3v2
+
+ private val _vorbis = mutableMapOf>()
+ /** The vorbis comments found in the file. Can have more than one value. */
+ val vorbis: Map>
+ get() = _vorbis
+
+ init {
+ for (i in 0 until metadata.length()) {
+ when (val tag = metadata[i]) {
+ is TextInformationFrame -> {
+ // Map TXXX frames differently so we can specifically index by their
+ // descriptions.
+ val id =
+ tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
+ ?: tag.id.sanitize()
+ val values = tag.values.map { it.sanitize() }.correctWhitespace()
+ if (values.isNotEmpty()) {
+ _id3v2[id] = values
+ }
+ }
+ is InternalFrame -> {
+ // Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
+ // which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
+ val id = "TXXX:${tag.description.sanitize().lowercase()}"
+ val value = tag.text
+ if (value.isNotEmpty()) {
+ _id3v2[id] = listOf(value)
+ }
+ }
+ is VorbisComment -> {
+ // Vorbis comment keys can be in any case, make them uppercase for simplicity.
+ val id = tag.key.sanitize().lowercase()
+ val value = tag.value.sanitize().correctWhitespace()
+ if (value != null) {
+ _vorbis.getOrPut(id) { mutableListOf() }.add(value)
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
+ * @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
+ * the Unicode replacement byte sequence.
+ */
+ private fun String.sanitize() = String(encodeToByteArray())
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt
similarity index 94%
rename from app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt
rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt
index f0474c364..5531d08ca 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.storage
+package org.oxycblt.auxio.music.filesystem
import android.view.View
import android.view.ViewGroup
@@ -41,7 +41,7 @@ class DirectoryAdapter(private val listener: Listener) :
override fun getItemCount() = dirs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- MusicDirViewHolder.new(parent)
+ MusicDirViewHolder.from(parent)
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener)
@@ -86,7 +86,7 @@ class DirectoryAdapter(private val listener: Listener) :
}
/**
- * A [RecyclerView.Recycler] that displays a [Directory]. Use [new] to create an instance.
+ * A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
@@ -107,7 +107,7 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt
similarity index 98%
rename from app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt
rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt
index ebe8a0ae2..b48e19c11 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt
@@ -15,15 +15,13 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.storage
+package org.oxycblt.auxio.music.filesystem
import android.content.Context
-import android.media.MediaExtractor
import android.media.MediaFormat
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.webkit.MimeTypeMap
-import com.google.android.exoplayer2.util.MimeTypes
import java.io.File
import org.oxycblt.auxio.R
diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt
similarity index 99%
rename from app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt
rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt
index 60bc797e9..8302aaf24 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.storage
+package org.oxycblt.auxio.music.filesystem
import android.annotation.SuppressLint
import android.content.ContentResolver
diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt
similarity index 85%
rename from app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt
rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt
index 90507e915..441cb7cbe 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt
@@ -15,13 +15,14 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.storage
+package org.oxycblt.auxio.music.filesystem
import android.net.Uri
import android.os.Bundle
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.view.LayoutInflater
+import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
@@ -30,7 +31,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
@@ -42,10 +42,8 @@ import org.oxycblt.auxio.util.showToast
class MusicDirsDialog :
ViewBindingDialogFragment(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this)
- private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
- private val storageManager: StorageManager by lifecycleObject { binding ->
- binding.context.getSystemServiceCompat(StorageManager::class)
- }
+ private var openDocumentTreeLauncher: ActivityResultLauncher? = null
+ private var storageManager: StorageManager? = null
override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicDirsBinding.inflate(inflater)
@@ -57,7 +55,10 @@ class MusicDirsDialog :
.setNeutralButton(R.string.lbl_add, null)
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
- val dirs = settings.getMusicDirs(storageManager)
+ val settings = Settings(requireContext())
+ val dirs =
+ settings.getMusicDirs(
+ requireNotNull(storageManager) { "StorageManager was not available" })
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (dirs != newDirs) {
logD("Committing changes")
@@ -67,7 +68,11 @@ class MusicDirsDialog :
}
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
- val launcher =
+ val context = requireContext()
+ val storageManager =
+ context.getSystemServiceCompat(StorageManager::class).also { storageManager = it }
+
+ openDocumentTreeLauncher =
registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
@@ -79,7 +84,10 @@ class MusicDirsDialog :
val dialog = it as AlertDialog
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher")
- launcher.launch(null)
+ requireNotNull(openDocumentTreeLauncher) {
+ "Document tree launcher was not available"
+ }
+ .launch(null)
}
}
@@ -88,7 +96,7 @@ class MusicDirsDialog :
itemAnimator = null
}
- var dirs = settings.getMusicDirs(storageManager)
+ var dirs = Settings(context).getMusicDirs(storageManager)
if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
@@ -127,6 +135,8 @@ class MusicDirsDialog :
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
super.onDestroyBinding(binding)
+ storageManager = null
+ openDocumentTreeLauncher = null
binding.dirsRecycler.adapter = null
}
@@ -153,7 +163,9 @@ class MusicDirsDialog :
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
- val dir = Directory.fromDocumentTreeUri(storageManager, treeUri)
+ val dir =
+ Directory.fromDocumentTreeUri(
+ requireNotNull(storageManager) { "StorageManager was not available" }, treeUri)
if (dir != null) {
dirAdapter.add(dir)
@@ -176,7 +188,7 @@ class MusicDirsDialog :
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
- companion object {
+ private companion object {
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt
similarity index 78%
rename from app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt
rename to app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt
index 05a2deeb7..95f193971 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt
@@ -15,61 +15,27 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.extractor
+package org.oxycblt.auxio.music.parsing
-import androidx.core.text.isDigitsOnly
-import java.util.UUID
-import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.settings.Settings
-import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
-/**
- * Unpack the track number from a combined track + disc [Int] field. These fields appear within
- * MediaStore's TRACK column, and combine the track and disc value into a single field where the
- * disc number is the 4th+ digit.
- * @return The track number extracted from the combined integer value, or null if the value was
- * zero.
- */
-fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
+/// --- GENERIC PARSING ---
/**
- * Unpack the disc number from a combined track + disc [Int] field. These fields appear within
- * MediaStore's TRACK column, and combine the track and disc value into a single field where the
- * disc number is the 4th+ digit.
- * @return The disc number extracted from the combined integer field, or null if the value was zero.
+ * Parse a multi-value tag based on the user configuration. If the value is already composed of more
+ * than one value, nothing is done. Otherwise, this function will attempt to split it based on the
+ * user's separator preferences.
+ * @param settings [Settings] required to obtain user separator configuration.
+ * @return A new list of one or more [String]s.
*/
-fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
-
-/**
- * Parse the number out of a combined number + total position [String] field. These fields often
- * appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /.
- * @return The number value extracted from the string field, or null if the value could not be
- * parsed or if the value was zero.
- */
-fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
-
-/**
- * Transform an [Int] year field into a [Date].
- * @return A [Date] consisting of the year value, or null if the value was zero.
- * @see Date.from
- */
-fun Int.toDate() = Date.from(this)
-
-/**
- * Parse an integer year field from a [String] and transform it into a [Date].
- * @return A [Date] consisting of the year value, or null if the value could not be parsed or if the
- * value was zero.
- * @see Date.from
- */
-fun String.parseYear() = toIntOrNull()?.toDate()
-
-/**
- * Parse an ISO-8601 timestamp [String] into a [Date].
- * @return A [Date] consisting of the year value plus one or more refinement values (ex. month,
- * day), or null if the timestamp was not valid.
- */
-fun String.parseTimestamp() = Date.from(this)
+fun List.parseMultiValue(settings: Settings) =
+ if (size == 1) {
+ first().maybeParseBySeparators(settings)
+ } else {
+ // Nothing to do.
+ this
+ }
/**
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
@@ -116,42 +82,38 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List {
}
/**
- * Parse a multi-value tag based on the user configuration. If the value is already composed of more
- * than one value, nothing is done. Otherwise, this function will attempt to split it based on the
- * user's separator preferences.
- * @param settings [Settings] required to obtain user separator configuration.
- * @return A new list of one or more [String]s.
+ * Fix trailing whitespace or blank contents in a [String].
+ * @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or
+ * empty.
*/
-fun List.parseMultiValue(settings: Settings) =
- if (size == 1) {
- get(0).maybeParseSeparators(settings)
- } else {
- // Nothing to do.
- this.map { it.trim() }
- }
+fun String.correctWhitespace() = trim().ifBlank { null }
+
+/**
+ * Fix trailing whitespace or blank contents within a list of [String]s.
+ * @return A list of non-blank strings with trailing whitespace removed.
+ */
+fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/**
* Attempt to parse a string by the user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration.
* @return A list of one or more [String]s that were split up by the user-defined separators.
*/
-fun String.maybeParseSeparators(settings: Settings): List {
+private fun String.maybeParseBySeparators(settings: Settings): List {
// Get the separators the user desires. If null, there's nothing to do.
val separators = settings.musicSeparators ?: return listOf(this)
- return splitEscaped { separators.contains(it) }.map { it.trim() }
+ return splitEscaped { separators.contains(it) }.correctWhitespace()
}
+/// --- ID3v2 PARSING ---
+
/**
- * Convert a [String] to a [UUID].
- * @return A [UUID] converted from the [String] value, or null if the value was not valid.
- * @see UUID.fromString
+ * Parse the number out of a ID3v2-style number + total position [String] field. These fields
+ * consist of a number and an (optional) total value delimited by a /.
+ * @return The number value extracted from the string field, or null if the value could not be
+ * parsed or if the value was zero.
*/
-fun String.toUuidOrNull(): UUID? =
- try {
- UUID.fromString(this)
- } catch (e: IllegalArgumentException) {
- null
- }
+fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/**
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
@@ -162,7 +124,7 @@ fun String.toUuidOrNull(): UUID? =
*/
fun List.parseId3GenreNames(settings: Settings) =
if (size == 1) {
- get(0).parseId3GenreNames(settings)
+ first().parseId3MultiValueGenre(settings)
} else {
// Nothing to split, just map any ID3v1 genres to their name counterparts.
map { it.parseId3v1Genre() ?: it }
@@ -172,8 +134,8 @@ fun List.parseId3GenreNames(settings: Settings) =
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
* @return A list of one or more genre names.
*/
-fun String.parseId3GenreNames(settings: Settings) =
- parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings)
+private fun String.parseId3MultiValueGenre(settings: Settings) =
+ parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
/**
* Parse an ID3v1 integer genre field.
@@ -182,15 +144,17 @@ fun String.parseId3GenreNames(settings: Settings) =
*/
private fun String.parseId3v1Genre(): String? {
// ID3v1 genres are a plain integer value without formatting, so in that case
- // try to index the genre table with such. If this fails, then try to compare it
- // to some other hard-coded values.
- val numeric = toIntOrNull() ?: return when (this) {
- // CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
- "CR" -> "Cover"
- "RX" -> "Remix"
- else -> null
- }
-
+ // try to index the genre table with such.
+ val numeric =
+ toIntOrNull()
+ // Not a numeric value, try some other fixed values.
+ ?: return when (this) {
+ // CR and RX are not technically ID3v1, but are formatted similarly to a plain
+ // number.
+ "CR" -> "Cover"
+ "RX" -> "Remix"
+ else -> null
+ }
return GENRE_TABLE.getOrNull(numeric)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt
new file mode 100644
index 000000000..c270f6d1d
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.parsing
+
+/**
+ * Defines the allowed separator characters that can be used to delimit multi-value tags.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+object Separators {
+ const val COMMA = ','
+ const val SEMICOLON = ';'
+ const val SLASH = '/'
+ const val PLUS = '+'
+ const val AND = '&'
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt
similarity index 66%
rename from app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt
rename to app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt
index c7e2ed55f..de4802a25 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.extractor
+package org.oxycblt.auxio.music.parsing
import android.os.Bundle
import android.view.LayoutInflater
@@ -27,7 +27,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.context
/**
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
@@ -35,8 +34,6 @@ import org.oxycblt.auxio.util.context
* @author Alexander Capehart (OxygenCobalt)
*/
class SeparatorsDialog : ViewBindingDialogFragment() {
- private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
-
override fun onCreateBinding(inflater: LayoutInflater) =
DialogSeparatorsBinding.inflate(inflater)
@@ -45,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() {
.setTitle(R.string.set_separators)
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
- settings.musicSeparators = getCurrentSeparators()
+ Settings(requireContext()).musicSeparators = getCurrentSeparators()
}
}
@@ -61,16 +58,18 @@ class SeparatorsDialog : ViewBindingDialogFragment() {
// More efficient to do one iteration through the separator list and initialize
// the corresponding CheckBox for each character instead of doing an iteration
// through the separator list for each CheckBox.
- (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: settings.musicSeparators)?.forEach {
- when (it) {
- SEPARATOR_COMMA -> binding.separatorComma.isChecked = true
- SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true
- SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true
- SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true
- SEPARATOR_AND -> binding.separatorAnd.isChecked = true
- else -> error("Unexpected separator in settings data")
+ (savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
+ ?: Settings(requireContext()).musicSeparators)
+ ?.forEach {
+ when (it) {
+ Separators.COMMA -> binding.separatorComma.isChecked = true
+ Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true
+ Separators.SLASH -> binding.separatorSlash.isChecked = true
+ Separators.PLUS -> binding.separatorPlus.isChecked = true
+ Separators.AND -> binding.separatorAnd.isChecked = true
+ else -> error("Unexpected separator in settings data")
+ }
}
- }
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -85,21 +84,15 @@ class SeparatorsDialog : ViewBindingDialogFragment() {
// of use a mapping that could feasibly drift from the actual layout.
var separators = ""
val binding = requireBinding()
- if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA
- if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON
- if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH
- if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS
- if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND
+ if (binding.separatorComma.isChecked) separators += Separators.COMMA
+ if (binding.separatorSemicolon.isChecked) separators += Separators.SEMICOLON
+ if (binding.separatorSlash.isChecked) separators += Separators.SLASH
+ if (binding.separatorPlus.isChecked) separators += Separators.PLUS
+ if (binding.separatorAnd.isChecked) separators += Separators.AND
return separators
}
- companion object {
- private val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
- // TODO: Move these to a more "Correct" location?
- private const val SEPARATOR_COMMA = ','
- private const val SEPARATOR_SEMICOLON = ';'
- private const val SEPARATOR_SLASH = '/'
- private const val SEPARATOR_PLUS = '+'
- private const val SEPARATOR_AND = '&'
+ private companion object {
+ const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
index 89c502d04..71bdc09b4 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt
@@ -39,7 +39,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = artists.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- ArtistChoiceViewHolder.new(parent)
+ ArtistChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
holder.bind(artists[position], listener)
@@ -58,7 +58,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
/**
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for
- * use with [ArtistChoiceAdapter]. Use [new] to create an instance.
+ * use with [ArtistChoiceAdapter]. Use [from] to create an instance.
*/
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
@@ -68,7 +68,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: ClickableListListener) {
- binding.root.setOnClickListener { listener.onClick(artist) }
+ listener.bind(artist, this)
binding.pickerImage.bind(artist)
binding.pickerName.text = artist.resolveName(binding.context)
}
@@ -79,7 +79,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt
index a8ea49687..bebfd66da 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt
@@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
import android.os.Bundle
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
@@ -40,8 +41,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState)
}
- override fun onClick(item: Item) {
- super.onClick(item)
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
+ super.onClick(item, viewHolder)
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
// User made a choice, navigate to it.
navModel.exploreNavigateTo(item)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
index 30b5dd996..0bf780537 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt
@@ -22,6 +22,7 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener
@@ -67,7 +68,7 @@ abstract class ArtistPickerDialog :
binding.pickerRecycler.adapter = null
}
- override fun onClick(item: Item) {
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
findNavController().navigateUp()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt
index b81e2604a..24ed8af43 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.picker
import android.os.Bundle
import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
@@ -41,8 +42,8 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState)
}
- override fun onClick(item: Item) {
- super.onClick(item)
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
+ super.onClick(item, viewHolder)
// User made a choice, play the given song from that artist.
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt
index 57f8322ce..49b5c758a 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt
@@ -1,3 +1,20 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
package org.oxycblt.auxio.music.picker
import android.view.View
@@ -22,7 +39,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = genres.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- GenreChoiceViewHolder.new(parent)
+ GenreChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
holder.bind(genres[position], listener)
@@ -41,7 +58,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
/**
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for
- * use with [GenreChoiceAdapter]. Use [new] to create an instance.
+ * use with [GenreChoiceAdapter]. Use [from] to create an instance.
*/
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
@@ -51,7 +68,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: ClickableListListener) {
- binding.root.setOnClickListener { listener.onClick(genre) }
+ listener.bind(genre, this)
binding.pickerImage.bind(genre)
binding.pickerName.text = genre.resolveName(binding.context)
}
@@ -62,7 +79,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt
index a5bba8d48..0a197ab0e 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt
@@ -1,3 +1,20 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
package org.oxycblt.auxio.music.picker
import android.os.Bundle
@@ -6,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener
@@ -21,7 +39,8 @@ import org.oxycblt.auxio.util.collectImmediately
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt)
*/
-class GenrePlaybackPickerDialog : ViewBindingDialogFragment(), ClickableListListener {
+class GenrePlaybackPickerDialog :
+ ViewBindingDialogFragment(), ClickableListListener {
private val pickerModel: PickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
// Information about what Song to show choices for is initially within the navigation arguments
@@ -56,11 +75,11 @@ class GenrePlaybackPickerDialog : ViewBindingDialogFragment(null)
/** The current item whose artists should be shown in the picker. Null if there is no item. */
- val currentItem: StateFlow get() = _currentItem
+ val currentItem: StateFlow
+ get() = _currentItem
private val _artistChoices = MutableStateFlow
>(listOf())
/** The current [Artist] choices. Empty if no item is shown in the picker. */
@@ -46,7 +47,7 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
get() = _genreChoices
override fun onCleared() {
- musicStore.removeCallback(this)
+ musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -75,5 +76,4 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
else -> {}
}
}
-
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
index 028fee788..1557ec4a8 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt
@@ -51,10 +51,10 @@ import org.oxycblt.auxio.util.logW
* @author Alexander Capehart (OxygenCobalt)
*/
class Indexer private constructor() {
- private var lastResponse: Response? = null
- private var indexingState: Indexing? = null
- private var controller: Controller? = null
- private var callback: Callback? = null
+ @Volatile private var lastResponse: Result? = null
+ @Volatile private var indexingState: Indexing? = null
+ @Volatile private var controller: Controller? = null
+ @Volatile private var listener: Listener? = null
/** Whether music loading is occurring or not. */
val isIndexing: Boolean
@@ -71,7 +71,7 @@ class Indexer private constructor() {
/**
* Register a [Controller] for this instance. This instance will handle any commands to start
* the music loading process. There can be only one [Controller] at a time. Will invoke all
- * [Callback] methods to initialize the instance with the current state.
+ * [Listener] methods to initialize the instance with the current state.
* @param controller The [Controller] to register. Will do nothing if already registered.
*/
@Synchronized
@@ -105,14 +105,14 @@ class Indexer private constructor() {
}
/**
- * Register the [Callback] for this instance. This can be used to receive rapid-fire updates to
- * the current music loading state. There can be only one [Callback] at a time. Will invoke all
- * [Callback] methods to initialize the instance with the current state.
- * @param callback The [Callback] to add.
+ * Register the [Listener] for this instance. This can be used to receive rapid-fire updates to
+ * the current music loading state. There can be only one [Listener] at a time. Will invoke all
+ * [Listener] methods to initialize the instance with the current state.
+ * @param listener The [Listener] to add.
*/
@Synchronized
- fun registerCallback(callback: Callback) {
- if (BuildConfig.DEBUG && this.callback != null) {
+ fun registerListener(listener: Listener) {
+ if (BuildConfig.DEBUG && this.listener != null) {
logW("Listener is already registered")
return
}
@@ -120,24 +120,24 @@ class Indexer private constructor() {
// Initialize the listener with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
- callback.onIndexerStateChanged(currentState)
- this.callback = callback
+ listener.onIndexerStateChanged(currentState)
+ this.listener = listener
}
/**
- * Unregister a [Callback] from this instance, preventing it from recieving any further updates.
- * @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if
- * invoked by another [Callback] implementation.
- * @see Callback
+ * Unregister a [Listener] from this instance, preventing it from recieving any further updates.
+ * @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if
+ * invoked by another [Listener] implementation.
+ * @see Listener
*/
@Synchronized
- fun unregisterCallback(callback: Callback) {
- if (BuildConfig.DEBUG && this.callback !== callback) {
+ fun unregisterListener(listener: Listener) {
+ if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller")
return
}
- this.callback = null
+ this.listener = null
}
/**
@@ -148,28 +148,14 @@ class Indexer private constructor() {
* be written, but no cache entries will be loaded into the new library.
*/
suspend fun index(context: Context, withCache: Boolean) {
- if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
- PackageManager.PERMISSION_DENIED) {
- // No permissions, signal that we can't do anything.
- emitCompletion(Response.NoPerms)
- return
- }
-
- val response =
+ val result =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, withCache)
- if (library != null) {
- // Successfully loaded a library.
- logD(
- "Music indexing completed successfully in " +
- "${System.currentTimeMillis() - start}ms")
- Response.Ok(library)
- } else {
- // Loaded a library, but it contained no music.
- logE("No music found")
- Response.NoMusic
- }
+ logD(
+ "Music indexing completed successfully in " +
+ "${System.currentTimeMillis() - start}ms")
+ Result.success(library)
} catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled")
@@ -178,10 +164,9 @@ class Indexer private constructor() {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed")
logE(e.stackTraceToString())
- Response.Err(e)
+ Result.failure(e)
}
-
- emitCompletion(response)
+ emitCompletion(result)
}
/**
@@ -212,9 +197,17 @@ class Indexer private constructor() {
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
- * @return A newly-loaded [MusicStore.Library], or null if nothing was loaded.
+ * @return A newly-loaded [MusicStore.Library].
+ * @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
+ * @throws NoMusicException If no music was found on the device.
*/
- private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library? {
+ private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library {
+ if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
+ PackageManager.PERMISSION_DENIED) {
+ // No permissions, signal that we can't do anything.
+ throw NoPermissionException()
+ }
+
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
@@ -236,12 +229,8 @@ class Indexer private constructor() {
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
- val songs = buildSongs(metadataExtractor, Settings(context))
- if (songs.isEmpty()) {
- // No songs, nothing else to do.
- return null
- }
-
+ val songs =
+ buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
@@ -249,7 +238,6 @@ class Indexer private constructor() {
val artists = buildArtists(songs, albums)
val genres = buildGenres(songs)
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
-
return MusicStore.Library(songs, albums, artists, genres)
}
@@ -388,17 +376,17 @@ class Indexer private constructor() {
val state =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state)
- callback?.onIndexerStateChanged(state)
+ listener?.onIndexerStateChanged(state)
}
/**
* Emit a new [State.Complete] state. This can be used to signal the completion of the music
* loading process to external code. Will check if the callee has not been canceled and thus has
* the ability to emit a new state
- * @param response The new [Response] to emit, representing the outcome of the music loading
+ * @param result The new [Result] to emit, representing the outcome of the music loading
* process.
*/
- private suspend fun emitCompletion(response: Response) {
+ private suspend fun emitCompletion(result: Result) {
yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
@@ -406,12 +394,12 @@ class Indexer private constructor() {
synchronized(this) {
// Do not check for redundancy here, as we actually need to notify a switch
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
- lastResponse = response
+ lastResponse = result
indexingState = null
// Signal that the music loading process has been completed.
- val state = State.Complete(response)
+ val state = State.Complete(result)
controller?.onIndexerStateChanged(state)
- callback?.onIndexerStateChanged(state)
+ listener?.onIndexerStateChanged(state)
}
}
}
@@ -427,10 +415,9 @@ class Indexer private constructor() {
/**
* Music loading has completed.
- * @param response The outcome of the music loading process.
- * @see Response
+ * @param result The outcome of the music loading process.
*/
- data class Complete(val response: Response) : State()
+ data class Complete(val result: Result) : State()
}
/**
@@ -451,35 +438,26 @@ class Indexer private constructor() {
class Songs(val current: Int, val total: Int) : Indexing()
}
- /** Represents the possible outcomes of the music loading process. */
- sealed class Response {
- /**
- * Music load was successful and produced a [MusicStore.Library].
- * @param library The loaded [MusicStore.Library].
- */
- data class Ok(val library: MusicStore.Library) : Response()
+ /** Thrown when the required permissions to load the music library have not been granted yet. */
+ class NoPermissionException : Exception() {
+ override val message: String
+ get() = "Not granted permissions to load music library"
+ }
- /**
- * Music loading encountered an unexpected error.
- * @param throwable The error thrown.
- */
- data class Err(val throwable: Throwable) : Response()
-
- /** Music loading occurred, but resulted in no music. */
- object NoMusic : Response()
-
- /** Music loading could not occur due to a lack of storage permissions. */
- object NoPerms : Response()
+ /** Thrown when no music was found on the device. */
+ class NoMusicException : Exception() {
+ override val message: String
+ get() = "Unable to find any music"
}
/**
* A listener for rapid-fire changes in the music loading state.
*
* This is only useful for code that absolutely must show the current loading process.
- * Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
+ * Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
* the [MusicStore.Library].
*/
- interface Callback {
+ interface Listener {
/**
* Called when the current state of the Indexer changed.
*
@@ -495,7 +473,7 @@ class Indexer private constructor() {
* Context that runs the music loading process. Implementations should be capable of running the
* background for long periods of time without android killing the process.
*/
- interface Controller : Callback {
+ interface Controller : Listener {
/**
* Called when a new music loading process was requested. Implementations should forward
* this to [index].
@@ -514,8 +492,7 @@ class Indexer private constructor() {
* system to load audio.
*/
val PERMISSION_READ_AUDIO =
- // TODO: Move elsewhere.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
Manifest.permission.READ_MEDIA_AUDIO
} else {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt
index d9d34fa02..67d301c36 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt
@@ -72,7 +72,6 @@ class IndexingNotification(private val context: Context) :
// Determinate state, show an active progress meter. Since these updates arrive
// highly rapidly, only update every 1.5 seconds to prevent notification rate
// limiting.
- // TODO: Can I port this to the playback notification somehow?
val now = SystemClock.elapsedRealtime()
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
return false
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
index b024890eb..2f89bbe3b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.system
import android.app.Service
import android.content.Intent
+import android.content.SharedPreferences
import android.database.ContentObserver
import android.os.Handler
import android.os.IBinder
@@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
-import org.oxycblt.auxio.music.storage.contentResolverSafe
+import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings
@@ -54,7 +55,8 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class IndexerService : Service(), Indexer.Controller, Settings.Callback {
+class IndexerService :
+ Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
@@ -81,7 +83,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
- settings = Settings(this, this)
+ settings = Settings(this)
+ settings.addListener(this)
indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music.
@@ -105,7 +108,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
- settings.release()
+ settings.removeListener(this)
indexer.unregisterController(this)
// Then cancel any remaining music loading jobs.
serviceJob.cancel()
@@ -126,11 +129,11 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onIndexerStateChanged(state: Indexer.State?) {
when (state) {
+ is Indexer.State.Indexing -> updateActiveSession(state.indexing)
is Indexer.State.Complete -> {
- if (state.response is Indexer.Response.Ok &&
- state.response.library != musicStore.library) {
+ val newLibrary = state.result.getOrNull()
+ if (newLibrary != null && newLibrary != musicStore.library) {
logD("Applying new library")
- val newLibrary = state.response.library
// We only care if the newly-loaded library is going to replace a previously
// loaded library.
if (musicStore.library != null) {
@@ -149,9 +152,6 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// handled right now.
updateIdleSession()
}
- is Indexer.State.Indexing -> {
- updateActiveSession(state.indexing)
- }
null -> {
// Null is the indeterminate state that occurs on app startup or after
// the cancellation of a load, so in that case we want to stop foreground
@@ -195,7 +195,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
- // this anymore.
+ // this anymore, or at least I only have to use it when the app task is not removed.
if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post()
}
@@ -230,7 +230,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// --- SETTING CALLBACKS ---
- override fun onSettingChanged(key: String) {
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
// Hook changes in music settings to a new music loading event.
getString(R.string.set_key_exclude_non_music),
@@ -287,8 +287,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
}
}
- companion object {
- private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
- private const val REINDEX_DELAY_MS = 500L
+ private companion object {
+ const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
+ const val REINDEX_DELAY_MS = 500L
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
index 37bd179e7..f29391284 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
@@ -23,6 +23,7 @@ import android.media.audiofx.AudioEffect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
+import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding
@@ -53,13 +54,7 @@ class PlaybackPanelFragment :
StyledSeekBar.Listener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
- // AudioEffect expects you to use startActivityForResult with the panel intent. There is no
- // contract analogue for this intent, so the generic contract is used instead.
- private val equalizerLauncher by lifecycleObject {
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- // Nothing to do
- }
- }
+ private var equalizerLauncher: ActivityResultLauncher? = null
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater)
@@ -70,6 +65,13 @@ class PlaybackPanelFragment :
) {
super.onBindingCreated(binding, savedInstanceState)
+ // AudioEffect expects you to use startActivityForResult with the panel intent. There is no
+ // contract analogue for this intent, so the generic contract is used instead.
+ equalizerLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ // Nothing to do
+ }
+
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets ->
val bars = insets.systemBarInsetsCompat
@@ -100,6 +102,7 @@ class PlaybackPanelFragment :
binding.playbackSeekBar.listener = this
// Set up actions
+ // TODO: Add better playback button accessibility
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
@@ -116,6 +119,7 @@ class PlaybackPanelFragment :
}
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
+ equalizerLauncher = null
binding.playbackToolbar.setOnMenuItemClickListener(null)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false
@@ -127,10 +131,9 @@ class PlaybackPanelFragment :
when (item.itemId) {
R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible.
- // TODO: Move this to a utility
val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
- // Provide audio session ID so equalizer can show options for this app
+ // Provide audio session ID so the equalizer can show options for this app
// in particular.
.putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
@@ -138,7 +141,10 @@ class PlaybackPanelFragment :
// music playback.
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
try {
- equalizerLauncher.launch(equalizerIntent)
+ requireNotNull(equalizerLauncher) {
+ "Equalizer panel launcher was not available"
+ }
+ .launch(equalizerIntent)
} catch (e: ActivityNotFoundException) {
requireContext().showToast(R.string.err_no_app)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index d2e5e4181..2f5ea9f14 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.context
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackViewModel(application: Application) :
- AndroidViewModel(application), PlaybackStateManager.Callback {
+ AndroidViewModel(application), PlaybackStateManager.Listener {
private val settings = Settings(application)
private val playbackManager = PlaybackStateManager.getInstance()
private var lastPositionJob: Job? = null
@@ -70,8 +70,8 @@ class PlaybackViewModel(application: Application) :
private val _artistPlaybackPickerSong = MutableStateFlow(null)
/**
- * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when
- * playing a [Song] from one of it's [Artist]s.
+ * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
+ * [Song] from one of it's [Artist]s.
* @see playFromArtist
*/
val artistPickerSong: StateFlow
@@ -79,8 +79,8 @@ class PlaybackViewModel(application: Application) :
private val _genrePlaybackPickerSong = MutableStateFlow(null)
/**
- * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing
- * a [Song] from one of it's [Genre]s.
+ * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
+ * [Song] from one of it's [Genre]s.
*/
val genrePickerSong: StateFlow
get() = _genrePlaybackPickerSong
@@ -93,11 +93,11 @@ class PlaybackViewModel(application: Application) :
get() = playbackManager.currentAudioSessionId
init {
- playbackManager.addCallback(this)
+ playbackManager.addListener(this)
}
override fun onCleared() {
- playbackManager.removeCallback(this)
+ playbackManager.removeListener(this)
}
override fun onIndexMoved(index: Int) {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
index 102fa38a2..baa4aa76c 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt
@@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable
-import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
@@ -27,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
+import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer
@@ -38,10 +38,11 @@ import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that shows an editable list of queue items.
- * @param listener A [Listener] to bind interactions to.
+ * @param listener A [EditableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
-class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter() {
+class QueueAdapter(private val listener: EditableListListener) :
+ RecyclerView.Adapter() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation
@@ -52,7 +53,7 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter
- binding.songDragHandle.performClick()
- if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
- listener.onPickUp(this)
- true
- } else false
- }
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -223,7 +199,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) =
+ fun from(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
index b86db473e..826d04270 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt
@@ -24,7 +24,10 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding
+import org.oxycblt.auxio.list.EditableListListener
+import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@@ -36,13 +39,11 @@ import org.oxycblt.auxio.util.logD
* A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt)
*/
-class QueueFragment : ViewBindingFragment(), QueueAdapter.Listener {
+class QueueFragment : ViewBindingFragment(), EditableListListener {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this)
- private val touchHelper: ItemTouchHelper by lifecycleObject {
- ItemTouchHelper(QueueDragCallback(queueModel))
- }
+ private var touchHelper: ItemTouchHelper? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
@@ -52,7 +53,10 @@ class QueueFragment : ViewBindingFragment(), QueueAdapter.
// --- UI SETUP ---
binding.queueRecycler.apply {
adapter = queueAdapter
- touchHelper.attachToRecyclerView(this)
+ touchHelper =
+ ItemTouchHelper(QueueDragCallback(queueModel)).also {
+ it.attachToRecyclerView(this)
+ }
}
// Sometimes the scroll can change without the listener being updated, so we also
@@ -77,13 +81,12 @@ class QueueFragment : ViewBindingFragment(), QueueAdapter.
binding.queueRecycler.adapter = null
}
- override fun onClick(viewHolder: RecyclerView.ViewHolder) {
- // Clicking on a queue item should start playing it.
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
queueModel.goto(viewHolder.bindingAdapterPosition)
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
- touchHelper.startDrag(viewHolder)
+ requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
private fun updateDivider() {
@@ -108,17 +111,25 @@ class QueueFragment : ViewBindingFragment(), QueueAdapter.
queueModel.finishReplace()
// If requested, scroll to a new item (occurs when the index moves)
- // TODO: Scroll to center/top instead of bottom
val scrollTo = queueModel.scrollTo
if (scrollTo != null) {
- // Do not scroll to indices that are in the currently visible range. As that would
- // lead to the queue jumping around every time goto is called.
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition()
- if (scrollTo !in start..end) {
- logD("Scrolling to new position")
+ val notInitialized =
+ start == RecyclerView.NO_POSITION || end == RecyclerView.NO_POSITION
+ // When we scroll, we want to scroll to the almost-top so the user can see
+ // future songs instead of past songs. The way we have to do this however is
+ // dependent on where we have to scroll to get to the currently playing song.
+ if (notInitialized || scrollTo < start) {
+ // We need to scroll upwards, or initialize the scroll, no need to offset
binding.queueRecycler.scrollToPosition(scrollTo)
+ } else if (scrollTo > end) {
+ // We need to scroll downwards, we need to offset by a screen of songs.
+ // This does have some error due to what the layout manager returns being
+ // somewhat mutable. This is considered okay.
+ binding.queueRecycler.scrollToPosition(
+ min(queue.lastIndex, scrollTo + (end - start)))
}
}
queueModel.finishScrollTo()
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
index 6502bef8c..fb30d6c5c 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
@@ -29,7 +29,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
+class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
private val playbackManager = PlaybackStateManager.getInstance()
private val _queue = MutableStateFlow(listOf())
@@ -47,7 +47,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
var scrollTo: Int? = null
init {
- playbackManager.addCallback(this)
+ playbackManager.addListener(this)
}
/**
@@ -135,6 +135,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
override fun onCleared() {
super.onCleared()
- playbackManager.removeCallback(this)
+ playbackManager.removeListener(this)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt
index 3c08e6b47..5cada49e2 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt
@@ -26,15 +26,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.context
/**
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
* @author Alexander Capehart (OxygenCobalt)
*/
class PreAmpCustomizeDialog : ViewBindingDialogFragment() {
- private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
-
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
@@ -42,9 +39,12 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() {
.setTitle(R.string.set_pre_amp)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
val binding = requireBinding()
- settings.replayGainPreAmp =
+ Settings(requireContext()).replayGainPreAmp =
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
}
+ .setNeutralButton(R.string.lbl_reset) { _, _ ->
+ Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
+ }
.setNegativeButton(R.string.lbl_cancel, null)
}
@@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() {
// First initialization, we need to supply the sliders with the values from
// settings. After this, the sliders save their own state, so we do not need to
// do any restore behavior.
- val preAmp = settings.replayGainPreAmp
+ val preAmp = Settings(requireContext()).replayGainPreAmp
binding.withTagsSlider.value = preAmp.with
binding.withoutTagsSlider.value = preAmp.without
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
index f0ee563f9..98450a763 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
@@ -18,33 +18,38 @@
package org.oxycblt.auxio.playback.replaygain
import android.content.Context
+import android.content.SharedPreferences
import com.google.android.exoplayer2.C
+import com.google.android.exoplayer2.Format
+import com.google.android.exoplayer2.Player
+import com.google.android.exoplayer2.Tracks
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
-import com.google.android.exoplayer2.metadata.Metadata
-import com.google.android.exoplayer2.metadata.id3.InternalFrame
-import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
-import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
+import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer
import kotlin.math.pow
+import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.extractor.Tags
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* An [AudioProcessor] that handles ReplayGain values and their amplification of the audio stream.
* Instead of leveraging the volume attribute like other implementations, this system manipulates
* the bitstream itself to modify the volume, which allows the use of positive ReplayGain values.
*
- * Note: This instance must be updated with a new [Metadata] every time the active track chamges.
+ * Note: This audio processor must be attached to a respective [Player] instance as a
+ * [Player.Listener] to function properly.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
+class ReplayGainAudioProcessor(private val context: Context) :
+ BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context)
+ private var lastFormat: Format? = null
private var volume = 1f
set(value) {
@@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
flush()
}
+ /**
+ * Add this instance to the components required for it to function correctly.
+ * @param player The [Player] to attach to. Should already have this instance as an audio
+ * processor.
+ */
+ fun addToListeners(player: Player) {
+ player.addListener(this)
+ settings.addListener(this)
+ }
+
+ /**
+ * Remove this instance from the components required for it to function correctly.
+ * @param player The [Player] to detach from. Should already have this instance as an audio
+ * processor.
+ */
+ fun releaseFromListeners(player: Player) {
+ player.removeListener(this)
+ settings.removeListener(this)
+ }
+
+ // --- OVERRIDES ---
+
+ override fun onTracksChanged(tracks: Tracks) {
+ super.onTracksChanged(tracks)
+ // Try to find the currently playing track so we can update the ReplayGain adjustment
+ // based on it.
+ for (group in tracks.groups) {
+ if (group.isSelected) {
+ for (i in 0 until group.length) {
+ if (group.isTrackSelected(i)) {
+ applyReplayGain(group.getTrackFormat(i))
+ return
+ }
+ }
+ }
+ }
+ // Nothing selected, apply nothing
+ applyReplayGain(null)
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+ if (key == context.getString(R.string.set_key_replay_gain) ||
+ key == context.getString(R.string.set_key_pre_amp_with) ||
+ key == context.getString(R.string.set_key_pre_amp_without)) {
+ // ReplayGain changed, we need to set it up again.
+ applyReplayGain(lastFormat)
+ }
+ }
+
// --- REPLAYGAIN PARSING ---
/**
- * Updates the volume adjustment based on the given [Metadata].
- * @param metadata The [Metadata] of the currently playing track, or null if the track has no
- * [Metadata].
+ * Updates the volume adjustment based on the given [Format].
+ * @param format The [Format] of the currently playing track, or null if nothing is playing.
*/
- fun applyReplayGain(metadata: Metadata?) {
- // TODO: Allow this to automatically obtain it's own [Metadata].
- val gain = metadata?.let(::parseReplayGain)
+ private fun applyReplayGain(format: Format?) {
+ lastFormat = format
+ val gain = parseReplayGain(format ?: return)
val preAmp = settings.replayGainPreAmp
val adjust =
if (gain != null) {
+ logD("Found ReplayGain adjustment $gain")
// ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain =
when (settings.replayGainMode) {
@@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
}
/**
- * Parse ReplayGain information from the given [Metadata].
- * @param metadata The [Metadata] to parse.
- * @return A [Gain] adjustment, or null if there was no adjustments to parse.
+ * Parse ReplayGain information from the given [Format].
+ * @param format The [Format] to parse.
+ * @return A [Adjustment] adjustment, or null if there were no valid adjustments.
*/
- private fun parseReplayGain(metadata: Metadata): Gain? {
- // TODO: Unify this parser with the music parser? They both grok Metadata.
-
+ private fun parseReplayGain(format: Format): Adjustment? {
+ val tags = Tags(format.metadata ?: return null)
var trackGain = 0f
var albumGain = 0f
- var found = false
- val tags = mutableListOf()
-
- for (i in 0 until metadata.length()) {
- val entry = metadata.get(i)
-
- val key: String?
- val value: String
-
- when (entry) {
- // ID3v2 text information frame, usually these are formatted in lowercase
- // (like "replaygain_track_gain"), but can also be uppercase. Make sure that
- // capitalization is consistent before continuing.
- is TextInformationFrame -> {
- key = entry.description
- value = entry.values[0]
- }
- // Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
- // frame by ExoPlayer (presumably to reduce duplication).
- is InternalFrame -> {
- key = entry.description
- value = entry.text
- }
- // Vorbis comment. These are nearly always uppercase, so a check for such is
- // skipped.
- is VorbisComment -> {
- key = entry.key
- value = entry.value
- }
- else -> continue
- }
-
- if (key in REPLAY_GAIN_TAGS) {
- // Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
- // or -.
- // Derived from vanilla music: https://github.com/vanilla-music/vanilla
- val gainValue =
- try {
- value.replace(Regex("[^\\d.-]"), "").toFloat()
- } catch (e: Exception) {
- 0f
- }
-
- tags.add(GainTag(unlikelyToBeNull(key), gainValue))
- }
+ // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
+ // replaygain_*_gain tag.
+ if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
+ tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { trackGain = it }
+ tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { albumGain = it }
+ tags.vorbis[TAG_RG_ALBUM_GAIN]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { trackGain = it }
+ tags.vorbis[TAG_RG_TRACK_GAIN]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { albumGain = it }
+ } else {
+ // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
+ // adjustment by 256 to get the gain. This is used alongside the base adjustment
+ // intrinsic to the format to create the normalized adjustment. That base adjustment
+ // is already handled by the media framework, so we just need to apply the more
+ // specific adjustments.
+ tags.vorbis[TAG_R128_TRACK_GAIN]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { trackGain = it / 256f }
+ tags.vorbis[TAG_R128_ALBUM_GAIN]
+ ?.run { first().parseReplayGainAdjustment() }
+ ?.let { albumGain = it / 256f }
}
- // Case 1: Normal ReplayGain, most commonly found on MPEG files.
- tags
- .findLast { tag -> tag.key.equals(TAG_RG_TRACK, ignoreCase = true) }
- ?.let { tag ->
- trackGain = tag.value
- found = true
- }
-
- tags
- .findLast { tag -> tag.key.equals(TAG_RG_ALBUM, ignoreCase = true) }
- ?.let { tag ->
- albumGain = tag.value
- found = true
- }
-
- // Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless
- // encodings to increase precision in volume adjustments.
- // While technically there is the R128 base gain in Opus files, that is automatically
- // applied by the media framework [which ExoPlayer relies on]. The only reason we would
- // want to read it is to zero previous ReplayGain values for being invalid, however there
- // is no demand to fix that edge case right now.
- tags
- .findLast { tag -> tag.key.equals(R128_TRACK, ignoreCase = true) }
- ?.let { tag ->
- trackGain += tag.value / 256f
- found = true
- }
-
- tags
- .findLast { tag -> tag.key.equals(R128_ALBUM, ignoreCase = true) }
- ?.let { tag ->
- albumGain += tag.value / 256f
- found = true
- }
-
- return if (found) {
- Gain(trackGain, albumGain)
+ return if (trackGain != 0f || albumGain != 0f) {
+ Adjustment(trackGain, albumGain)
} else {
null
}
}
+
+ /**
+ * Parse a ReplayGain adjustment into a float value.
+ * @return A parsed adjustment float, or null if the adjustment had invalid formatting.
+ */
+ private fun String.parseReplayGainAdjustment() =
+ replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()
+
// --- AUDIO PROCESSOR IMPLEMENTATION ---
override fun onConfigure(
@@ -271,22 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
* @param track The track adjustment (in dB), or 0 if it is not present.
* @param album The album adjustment (in dB), or 0 if it is not present.
*/
- private data class Gain(val track: Float, val album: Float)
+ private data class Adjustment(val track: Float, val album: Float)
- /**
- * A raw ReplayGain adjustment.
- * @param key The tag's key.
- * @param value The tag's adjustment, in dB.
- */
- private data class GainTag(val key: String, val value: Float)
- // TODO: Try to phase this out
+ private companion object {
+ const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
+ const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
+ const val TAG_R128_TRACK_GAIN = "r128_track_gain"
+ const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
- companion object {
- private const val TAG_RG_TRACK = "replaygain_track_gain"
- private const val TAG_RG_ALBUM = "replaygain_album_gain"
- private const val R128_TRACK = "r128_track_gain"
- private const val R128_ALBUM = "r128_album_gain"
-
- private val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
+ /**
+ * Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
+ * https://github.com/vanilla-music/vanilla
+ */
+ val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt
index b7c533b85..7c2487458 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt
@@ -85,6 +85,9 @@ interface InternalPlayer {
data class Open(val uri: Uri) : Action()
}
+ /**
+ * A representation of the current state of audio playback. Use [from] to create an instance.
+ */
class State
private constructor(
/** Whether the player is actively playing audio or set to play audio in the future. */
@@ -157,7 +160,7 @@ interface InternalPlayer {
* @param isAdvancing Whether the player is actively playing audio in this moment.
* @param positionMs The current position of the player.
*/
- fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
+ fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
State(
isPlaying,
// Minor sanity check: Make sure that advancing can't occur if already paused.
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
index f9fe57c12..fb2995839 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
@@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.playback.state.PlaybackStateManager.Callback
+import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logW
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use
* [org.oxycblt.auxio.playback.system.PlaybackService].
*
- * Internal consumers should usually use [Callback], however the component that manages the player
+ * Internal consumers should usually use [Listener], however the component that manages the player
* itself should instead use [InternalPlayer].
*
* All access should be done with [PlaybackStateManager.getInstance].
@@ -54,35 +54,40 @@ import org.oxycblt.auxio.util.logW
*/
class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance()
- private val callbacks = mutableListOf()
- private var internalPlayer: InternalPlayer? = null
- private var pendingAction: InternalPlayer.Action? = null
- private var isInitialized = false
+ private val listeners = mutableListOf()
+ @Volatile private var internalPlayer: InternalPlayer? = null
+ @Volatile private var pendingAction: InternalPlayer.Action? = null
+ @Volatile private var isInitialized = false
/** The currently playing [Song]. Null if nothing is playing. */
val song
get() = queue.getOrNull(index)
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
+ @Volatile
var parent: MusicParent? = null
private set
- private var _queue = mutableListOf()
+ @Volatile private var _queue = mutableListOf()
/** The current queue. */
val queue
get() = _queue
/** The position of the currently playing item in the queue. */
+ @Volatile
var index = -1
private set
/** The current [InternalPlayer] state. */
- var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
+ @Volatile
+ var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
private set
/** The current [RepeatMode] */
+ @Volatile
var repeatMode = RepeatMode.NONE
set(value) {
field = value
notifyRepeatModeChanged()
}
/** Whether the queue is shuffled. */
+ @Volatile
var isShuffled = false
private set
/**
@@ -93,32 +98,32 @@ class PlaybackStateManager private constructor() {
get() = internalPlayer?.audioSessionId
/**
- * Add a [Callback] to this instance. This can be used to receive changes in the playback state.
- * Will immediately invoke [Callback] methods to initialize the instance with the current state.
- * @param callback The [Callback] to add.
- * @see Callback
+ * Add a [Listener] to this instance. This can be used to receive changes in the playback state.
+ * Will immediately invoke [Listener] methods to initialize the instance with the current state.
+ * @param listener The [Listener] to add.
+ * @see Listener
*/
@Synchronized
- fun addCallback(callback: Callback) {
+ fun addListener(listener: Listener) {
if (isInitialized) {
- callback.onNewPlayback(index, queue, parent)
- callback.onRepeatChanged(repeatMode)
- callback.onShuffledChanged(isShuffled)
- callback.onStateChanged(playerState)
+ listener.onNewPlayback(index, queue, parent)
+ listener.onRepeatChanged(repeatMode)
+ listener.onShuffledChanged(isShuffled)
+ listener.onStateChanged(playerState)
}
- callbacks.add(callback)
+ listeners.add(listener)
}
/**
- * Remove a [Callback] from this instance, preventing it from recieving any further updates.
- * @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
+ * Remove a [Listener] from this instance, preventing it from recieving any further updates.
+ * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
- * @see Callback
+ * @see Listener
*/
@Synchronized
- fun removeCallback(callback: Callback) {
- callbacks.remove(callback)
+ fun removeListener(listener: Listener) {
+ listeners.remove(listener)
}
/**
@@ -521,10 +526,9 @@ class PlaybackStateManager private constructor() {
* @param database The [PlaybackStateDatabase] to clear te state from
* @return If the state was cleared, false otherwise.
*/
- suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
- logD("Wiping state")
-
- return try {
+ suspend fun wipeState(database: PlaybackStateDatabase) =
+ try {
+ logD("Wiping state")
withContext(Dispatchers.IO) { database.write(null) }
true
} catch (e: Exception) {
@@ -532,7 +536,6 @@ class PlaybackStateManager private constructor() {
logE(e.stackTraceToString())
false
}
- }
/**
* Update the playback state to align with a new [MusicStore.Library].
@@ -586,52 +589,52 @@ class PlaybackStateManager private constructor() {
// --- CALLBACKS ---
private fun notifyIndexMoved() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onIndexMoved(index)
}
}
private fun notifyQueueChanged() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onQueueChanged(queue)
}
}
private fun notifyQueueReworked() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onQueueReworked(index, queue)
}
}
private fun notifyNewPlayback() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onNewPlayback(index, queue, parent)
}
}
private fun notifyStateChanged() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onStateChanged(playerState)
}
}
private fun notifyRepeatModeChanged() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onRepeatChanged(repeatMode)
}
}
private fun notifyShuffledChanged() {
- for (callback in callbacks) {
+ for (callback in listeners) {
callback.onShuffledChanged(isShuffled)
}
}
/**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
- * [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
+ * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
*/
- interface Callback {
+ interface Listener {
/**
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
index c3d8041f7..863d71b6b 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.system
import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
@@ -43,11 +44,13 @@ import org.oxycblt.auxio.util.logD
* A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent].
* @param context [Context] required to initialize components.
- * @param callback [Callback] to forward notification updates to.
+ * @param listener [Listener] to forward notification updates to.
* @author Alexander Capehart (OxygenCobalt)
*/
-class MediaSessionComponent(private val context: Context, private val callback: Callback) :
- MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
+class MediaSessionComponent(private val context: Context, private val listener: Listener) :
+ MediaSessionCompat.Callback(),
+ PlaybackStateManager.Listener,
+ SharedPreferences.OnSharedPreferenceChangeListener {
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply {
isActive = true
@@ -55,13 +58,13 @@ class MediaSessionComponent(private val context: Context, private val callback:
}
private val playbackManager = PlaybackStateManager.getInstance()
- private val settings = Settings(context, this)
+ private val settings = Settings(context)
private val notification = NotificationComponent(context, mediaSession.sessionToken)
private val provider = BitmapProvider(context)
init {
- playbackManager.addCallback(this)
+ playbackManager.addListener(this)
mediaSession.setCallback(this)
}
@@ -79,15 +82,15 @@ class MediaSessionComponent(private val context: Context, private val callback:
*/
fun release() {
provider.release()
- settings.release()
- playbackManager.removeCallback(this)
+ settings.removeListener(this)
+ playbackManager.removeListener(this)
mediaSession.apply {
isActive = false
release()
}
}
- // --- PLAYBACKSTATEMANAGER CALLBACKS ---
+ // --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.song, playbackManager.parent)
@@ -113,7 +116,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) {
- callback.onPostNotification(notification)
+ listener.onPostNotification(notification)
}
}
@@ -139,9 +142,9 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSecondaryAction()
}
- // --- SETTINGSMANAGER CALLBACKS ---
+ // --- SETTINGS OVERRIDES ---
- override fun onSettingChanged(key: String) {
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
context.getString(R.string.set_key_cover_mode) ->
updateMediaMetadata(playbackManager.song, playbackManager.parent)
@@ -149,7 +152,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
}
}
- // --- MEDIASESSION CALLBACKS ---
+ // --- MEDIASESSION OVERRIDES ---
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras)
@@ -306,7 +309,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
val metadata = builder.build()
mediaSession.setMetadata(metadata)
notification.updateMetadata(metadata)
- callback.onPostNotification(notification)
+ listener.onPostNotification(notification)
}
})
}
@@ -393,12 +396,12 @@ class MediaSessionComponent(private val context: Context, private val callback:
}
if (!provider.isBusy) {
- callback.onPostNotification(notification)
+ listener.onPostNotification(notification)
}
}
/** An interface for handling changes in the notification configuration. */
- interface Callback {
+ interface Listener {
/**
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
* @param notification The new [NotificationComponent].
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
index 823efd20f..21cb16676 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
@@ -148,9 +148,9 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
.build()
- companion object {
+ private companion object {
/** Notification channel used by solely the playback notification. */
- private val CHANNEL_INFO =
+ val CHANNEL_INFO =
ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_playback)
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
index d35555cfe..c615a27ab 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
@@ -31,7 +31,6 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.RenderersFactory
-import com.google.android.exoplayer2.Tracks
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
@@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
@@ -79,9 +77,8 @@ class PlaybackService :
Service(),
Player.Listener,
InternalPlayer,
- MediaSessionComponent.Callback,
- Settings.Callback,
- MusicStore.Callback {
+ MediaSessionComponent.Listener,
+ MusicStore.Listener {
// Player components
private lateinit var player: ExoPlayer
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
@@ -143,13 +140,14 @@ class PlaybackService :
true)
.build()
.also { it.addListener(this) }
+ replayGainProcessor.addToListeners(player)
// Initialize the core service components
- settings = Settings(this, this)
+ settings = Settings(this)
foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
playbackManager.registerInternalPlayer(this)
- musicStore.addCallback(this)
+ musicStore.addListener(this)
widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, this)
registerReceiver(
@@ -185,12 +183,11 @@ class PlaybackService :
super.onDestroy()
foregroundManager.release()
- settings.release()
// Pause just in case this destruction was unexpected.
playbackManager.setPlaying(false)
playbackManager.unregisterInternalPlayer(this)
- musicStore.removeCallback(this)
+ musicStore.removeListener(this)
unregisterReceiver(systemReceiver)
serviceJob.cancel()
@@ -198,6 +195,7 @@ class PlaybackService :
widgetComponent.release()
mediaSessionComponent.release()
+ replayGainProcessor.releaseFromListeners(player)
player.release()
if (openAudioEffectSession) {
// Make sure to close the audio session when we release the player.
@@ -217,7 +215,7 @@ class PlaybackService :
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun getState(durationMs: Long) =
- InternalPlayer.State.new(
+ InternalPlayer.State.from(
player.playWhenReady,
player.isPlaying,
// The position value can be below zero or past the expected duration, make
@@ -302,24 +300,6 @@ class PlaybackService :
playbackManager.next()
}
- override fun onTracksChanged(tracks: Tracks) {
- super.onTracksChanged(tracks)
- // Try to find the currently playing track so we can update ReplayGainAudioProcessor
- // with it.
- for (group in tracks.groups) {
- if (group.isSelected) {
- for (i in 0 until group.length) {
- if (group.isTrackSelected(i)) {
- replayGainProcessor.applyReplayGain(group.getTrackFormat(i).metadata)
- break
- }
- }
-
- break
- }
- }
- }
-
// --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -329,16 +309,6 @@ class PlaybackService :
}
}
- // --- SETTINGSMANAGER OVERRIDES ---
-
- override fun onSettingChanged(key: String) {
- if (key == getString(R.string.set_key_replay_gain) ||
- key == getString(R.string.set_key_pre_amp_with) ||
- key == getString(R.string.set_key_pre_amp_without)) {
- onTracksChanged(player.currentTracks)
- }
- }
-
// --- OTHER FUNCTIONS ---
private fun broadcastAudioEffectAction(event: String) {
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
index 3f9cca499..a1260859b 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
@@ -51,11 +51,11 @@ class SearchAdapter(private val listener: SelectableListListener) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
- AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.new(parent)
- ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
- GenreViewHolder.VIEW_TYPE -> GenreViewHolder.new(parent)
- HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
+ SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
+ AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent)
+ ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
+ GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
+ HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType")
}
@@ -81,9 +81,9 @@ class SearchAdapter(private val listener: SelectableListListener) :
differ.submitList(newList, callback)
}
- companion object {
+ private companion object {
/** A comparator that can be used with DiffUtil. */
- private val DIFF_CALLBACK =
+ val DIFF_CALLBACK =
object : SimpleItemCallback- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
index 0a7154855..f9055e96d 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
@@ -53,10 +53,8 @@ import org.oxycblt.auxio.util.*
class SearchFragment : ListFragment() {
private val searchModel: SearchViewModel by androidViewModels()
private val searchAdapter = SearchAdapter(this)
+ private var imm: InputMethodManager? = null
private var launchedKeyboard = false
- private val imm: InputMethodManager by lifecycleObject { binding ->
- binding.context.getSystemServiceCompat(InputMethodManager::class)
- }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -74,13 +72,15 @@ class SearchFragment : ListFragment() {
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
+ imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
+
binding.searchToolbar.apply {
// Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
setNavigationOnClickListener {
// Keyboard is no longer needed.
- imm.hide()
+ hideKeyboard()
findNavController().navigateUp()
}
@@ -95,7 +95,7 @@ class SearchFragment : ListFragment() {
if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown
- imm.show(this)
+ showKeyboard(this)
launchedKeyboard = true
}
}
@@ -184,7 +184,7 @@ class SearchFragment : ListFragment() {
else -> return
}
// Keyboard is no longer needed.
- imm.hide()
+ hideKeyboard()
findNavController().navigate(action)
}
@@ -193,7 +193,7 @@ class SearchFragment : ListFragment() {
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard.
- imm.hide()
+ hideKeyboard()
}
}
@@ -201,15 +201,19 @@ class SearchFragment : ListFragment() {
* Safely focus the keyboard on a particular [View].
* @param view The [View] to focus the keyboard on.
*/
- private fun InputMethodManager.show(view: View) {
+ private fun showKeyboard(view: View) {
view.apply {
requestFocus()
- postDelayed(200) { showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }
+ postDelayed(200) {
+ requireNotNull(imm) { "InputMethodManager was not available" }
+ .showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
+ }
}
}
/** Safely hide the keyboard from this view. */
- private fun InputMethodManager.hide() {
- hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
+ private fun hideKeyboard() {
+ requireNotNull(imm) { "InputMethodManager was not available" }
+ .hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
index a088e32e7..72ea04fae 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt
@@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchViewModel(application: Application) :
- AndroidViewModel(application), MusicStore.Callback {
+ AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(context)
private var lastQuery: String? = null
@@ -55,12 +55,12 @@ class SearchViewModel(application: Application) :
get() = _searchResults
init {
- musicStore.addCallback(this)
+ musicStore.addListener(this)
}
override fun onCleared() {
super.onCleared()
- musicStore.removeCallback(this)
+ musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@@ -212,11 +212,11 @@ class SearchViewModel(application: Application) :
search(lastQuery)
}
- companion object {
+ private companion object {
/**
* Converts the output of [Normalizer] to remove any junk characters added by it's
* replacements.
*/
- private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
+ val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
index 81c52a22a..bb4d9109b 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
@@ -152,14 +152,14 @@ class AboutFragment : ViewBindingFragment() {
startActivity(chooserIntent)
}
- companion object {
+ private companion object {
/** The URL to the source code. */
- private const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
+ const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
/** The URL to the app wiki. */
- private const val LINK_WIKI = "$LINK_SOURCE/wiki"
+ const val LINK_WIKI = "$LINK_SOURCE/wiki"
/** The URL to the licenses wiki page. */
- private const val LINK_LICENSES = "$LINK_WIKI/Licenses"
+ const val LINK_LICENSES = "$LINK_WIKI/Licenses"
/** The URL to the app author. */
- private const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
+ const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
index f5711e221..2412b5ee9 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.settings
import android.content.Context
import android.content.SharedPreferences
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate
@@ -30,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.Sort
-import org.oxycblt.auxio.music.storage.Directory
-import org.oxycblt.auxio.music.storage.MusicDirectories
+import org.oxycblt.auxio.music.filesystem.Directory
+import org.oxycblt.auxio.music.filesystem.MusicDirectories
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
@@ -40,20 +41,14 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
- * A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Object
- * mutability
+ * A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
+ * mutability is dependent on how they are used in app. Immutable members are often only modified by
+ * the preferences view, while mutable members are modified elsewhere.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Settings(private val context: Context, private val callback: Callback? = null) :
- SharedPreferences.OnSharedPreferenceChangeListener {
+class Settings(private val context: Context) {
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
- init {
- if (callback != null) {
- inner.registerOnSharedPreferenceChangeListener(this)
- }
- }
-
/**
* Migrate any settings from an old version into their modern counterparts. This can cause data
* loss depending on the feasibility of a migration.
@@ -154,27 +149,19 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
/**
- * Release this instance and any callbacks held by it. This is not needed if no [Callback] was
- * originally attached.
+ * Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
+ * @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
*/
- fun release() {
- inner.unregisterOnSharedPreferenceChangeListener(this)
- }
-
- override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
- unlikelyToBeNull(callback).onSettingChanged(key)
+ fun addListener(listener: OnSharedPreferenceChangeListener) {
+ inner.registerOnSharedPreferenceChangeListener(listener)
}
/**
- * Simplified callback for settings changes.
+ * Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
+ * settings updates from being sent to ti.t
*/
- interface Callback {
- // TODO: Refactor this lifecycle
- /**
- * Called when a setting has changed.
- * @param key The key of the setting that changed.
- */
- fun onSettingChanged(key: String)
+ fun removeListener(listener: OnSharedPreferenceChangeListener) {
+ inner.unregisterOnSharedPreferenceChangeListener(listener)
}
// --- VALUES ---
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt
index a25d7a718..8afa0ec8d 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt
@@ -162,8 +162,8 @@ constructor(
}
}
- companion object {
- private val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
+ private companion object {
+ val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
lazyReflectedField(Preference::class, "mDefaultValue")
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt
index b66bfe309..72f8f3383 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt
@@ -24,7 +24,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
/**
- * The companion dialog to [IntListPreference]. Use [new] to create an instance.
+ * The companion dialog to [IntListPreference]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
@@ -62,11 +62,10 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
* @param preference The [IntListPreference] to display.
* @return A new instance.
*/
- fun new(preference: IntListPreference): IntListPreferenceDialog {
- return IntListPreferenceDialog().apply {
+ fun from(preference: IntListPreference) =
+ IntListPreferenceDialog().apply {
// Populate the key field required by PreferenceDialogFragmentCompat.
arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
}
- }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt
index da944e309..3eebc3176 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt
@@ -76,7 +76,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
is IntListPreference -> {
// Copy the built-in preference dialog launching code into our project so
// we can automatically use the provided preference class.
- val dialog = IntListPreferenceDialog.new(preference)
+ val dialog = IntListPreferenceDialog.from(preference)
dialog.setTargetFragment(this, 0)
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
}
@@ -104,46 +104,44 @@ class PreferenceFragment : PreferenceFragmentCompat() {
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
- val context = requireContext()
-
// Hook generic preferences to their specified preferences
// TODO: These seem like good things to put into a side navigation view, if I choose to
// do one.
when (preference.key) {
- context.getString(R.string.set_key_save_state) -> {
+ getString(R.string.set_key_save_state) -> {
playbackModel.savePlaybackState { saved ->
// Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached.
if (saved) {
- this.context?.showToast(R.string.lbl_state_saved)
+ context?.showToast(R.string.lbl_state_saved)
} else {
- this.context?.showToast(R.string.err_did_not_save)
+ context?.showToast(R.string.err_did_not_save)
}
}
}
- context.getString(R.string.set_key_wipe_state) -> {
+ getString(R.string.set_key_wipe_state) -> {
playbackModel.wipePlaybackState { wiped ->
if (wiped) {
// Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached.
- this.context?.showToast(R.string.lbl_state_wiped)
+ context?.showToast(R.string.lbl_state_wiped)
} else {
- this.context?.showToast(R.string.err_did_not_wipe)
+ context?.showToast(R.string.err_did_not_wipe)
}
}
}
- context.getString(R.string.set_key_restore_state) ->
+ getString(R.string.set_key_restore_state) ->
playbackModel.tryRestorePlaybackState { restored ->
if (restored) {
// Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached.
- this.context?.showToast(R.string.lbl_state_restored)
+ context?.showToast(R.string.lbl_state_restored)
} else {
- this.context?.showToast(R.string.err_did_not_restore)
+ context?.showToast(R.string.err_did_not_restore)
}
}
- context.getString(R.string.set_key_reindex) -> musicModel.refresh()
- context.getString(R.string.set_key_rescan) -> musicModel.rescan()
+ getString(R.string.set_key_reindex) -> musicModel.refresh()
+ getString(R.string.set_key_rescan) -> musicModel.rescan()
else -> return super.onPreferenceTreeClick(preference)
}
@@ -151,8 +149,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
}
private fun setupPreference(preference: Preference) {
- val context = requireActivity()
- val settings = Settings(context)
+ val settings = Settings(requireContext())
if (!preference.isVisible) {
// Nothing to do.
@@ -165,30 +162,31 @@ class PreferenceFragment : PreferenceFragmentCompat() {
}
when (preference.key) {
- context.getString(R.string.set_key_theme) -> {
+ getString(R.string.set_key_theme) -> {
preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value ->
AppCompatDelegate.setDefaultNightMode(value as Int)
true
}
}
- context.getString(R.string.set_key_accent) -> {
- preference.summary = context.getString(settings.accent.name)
+ getString(R.string.set_key_accent) -> {
+ preference.summary = getString(settings.accent.name)
}
- context.getString(R.string.set_key_black_theme) -> {
+ getString(R.string.set_key_black_theme) -> {
preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
- if (context.isNight) {
- context.recreate()
+ val activity = requireActivity()
+ if (activity.isNight) {
+ activity.recreate()
}
true
}
}
- context.getString(R.string.set_key_cover_mode) -> {
+ getString(R.string.set_key_cover_mode) -> {
preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
- Coil.imageLoader(context).memoryCache?.clear()
+ Coil.imageLoader(requireContext()).memoryCache?.clear()
true
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/AuxioAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt
similarity index 90%
rename from app/src/main/java/org/oxycblt/auxio/ui/AuxioAppBarLayout.kt
rename to app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt
index 91e122dad..3a14709b5 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/AuxioAppBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/CoordinatorAppBarLayout.kt
@@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
-open class AuxioAppBarLayout
+open class CoordinatorAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppBarLayout(context, attrs, defStyleAttr) {
@@ -68,14 +68,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
/**
- * Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
- * jumping around.
- * @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
+ * Expand this [AppBarLayout] with respect to the current [RecyclerView] at
+ * [liftOnScrollTargetViewId], preventing it from jumping around.
*/
- fun expandWithRecycler(recycler: RecyclerView?) {
- // TODO: Is it possible to use liftOnScrollTargetViewId to avoid the RecyclerView arg?
+ fun expandWithScrollingRecycler() {
setExpanded(true)
- recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
+ (findScrollingChild() as? RecyclerView)?.let {
+ addOnOffsetChangedListener(ExpansionHackListener(it))
+ }
}
override fun onDetachedFromWindow() {
@@ -136,8 +136,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
- companion object {
+ private companion object {
/** @see AppBarLayout.BaseBehavior.MAX_OFFSET_ANIMATION_DURATION */
- private const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600
+ const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
index 905862d7d..2ea31a0c4 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt
@@ -92,8 +92,8 @@ class NavigationViewModel : ViewModel() {
/**
* Navigate to one of the parent [Artist]'s of the given [Song].
- * @param song The [Song] to navigate with. If there are multiple parent [Artist]s,
- * a picker dialog will be shown.
+ * @param song The [Song] to navigate with. If there are multiple parent [Artist]s, a picker
+ * dialog will be shown.
*/
fun exploreNavigateToParentArtist(song: Song) {
exploreNavigateToParentArtistImpl(song, song.artists)
@@ -101,8 +101,8 @@ class NavigationViewModel : ViewModel() {
/**
* Navigate to one of the parent [Artist]'s of the given [Album].
- * @param album The [Album] to navigate with. If there are multiple parent [Artist]s,
- * a picker dialog will be shown.
+ * @param album The [Album] to navigate with. If there are multiple parent [Artist]s, a picker
+ * dialog will be shown.
*/
fun exploreNavigateToParentArtist(album: Album) {
exploreNavigateToParentArtistImpl(album, album.artists)
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt
index 177ae8735..fd362131d 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt
@@ -23,11 +23,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -37,7 +34,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
abstract class ViewBindingDialogFragment : DialogFragment() {
private var _binding: VB? = null
- private var lifecycleObjects = mutableListOf>()
/**
* Configure the [AlertDialog.Builder] during [onCreateDialog].
@@ -85,25 +81,6 @@ abstract class ViewBindingDialogFragment : DialogFragment() {
}
}
- /**
- * Delegate to automatically create and destroy an object derived from the [ViewBinding].
- * @param create Block to create the object from the [ViewBinding].
- */
- fun lifecycleObject(create: (VB) -> T): ReadOnlyProperty {
- lifecycleObjects.add(LifecycleObject(null, create))
-
- return object : ReadOnlyProperty {
- private val objIdx = lifecycleObjects.lastIndex
-
- @Suppress("UNCHECKED_CAST")
- override fun getValue(thisRef: Fragment, property: KProperty<*>) =
- requireNotNull(lifecycleObjects[objIdx].data) {
- "Cannot access lifecycle object when view does not exist"
- }
- as T
- }
- }
-
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -119,9 +96,6 @@ abstract class ViewBindingDialogFragment : DialogFragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val binding = unlikelyToBeNull(_binding)
- // Populate lifecycle-dependent objects
- lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
// Apply the newly-configured view to the dialog.
@@ -132,21 +106,8 @@ abstract class ViewBindingDialogFragment : DialogFragment() {
final override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding))
- // Clear the lifecycle-dependent objects
- lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null
logD("Fragment destroyed")
}
-
- /** Internal implementation of [lifecycleObject]. */
- private data class LifecycleObject(var data: T?, val create: (VB) -> T) {
- fun populate(binding: VB) {
- data = create(binding)
- }
-
- fun clear() {
- data = null
- }
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt
index f0a005582..b5ece20e2 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt
@@ -23,8 +23,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -34,7 +32,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
abstract class ViewBindingFragment : Fragment() {
private var _binding: VB? = null
- private var lifecycleObjects = mutableListOf>()
/**
* Inflate the [ViewBinding] during [onCreateView].
@@ -75,26 +72,6 @@ abstract class ViewBindingFragment : Fragment() {
}
}
- /**
- * Delegate to automatically create and destroy an object derived from the [ViewBinding].
- * @param create Block to create the object from the [ViewBinding].
- */
- fun lifecycleObject(create: (VB) -> T): ReadOnlyProperty {
- // TODO: Phase this out.
- lifecycleObjects.add(LifecycleObject(null, create))
-
- return object : ReadOnlyProperty {
- private val objIdx = lifecycleObjects.lastIndex
-
- @Suppress("UNCHECKED_CAST")
- override fun getValue(thisRef: Fragment, property: KProperty<*>) =
- requireNotNull(lifecycleObjects[objIdx].data) {
- "Cannot access lifecycle object when view does not exist"
- }
- as T
- }
- }
-
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -103,9 +80,6 @@ abstract class ViewBindingFragment : Fragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val binding = unlikelyToBeNull(_binding)
- // Populate lifecycle-dependent objects
- lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created")
@@ -114,21 +88,8 @@ abstract class ViewBindingFragment : Fragment() {
final override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding))
- // Clear the lifecycle-dependent objects
- lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null
logD("Fragment destroyed")
}
-
- /** Internal implementation of [lifecycleObject]. */
- private data class LifecycleObject(var data: T?, val create: (VB) -> T) {
- fun populate(binding: VB) {
- data = create(binding)
- }
-
- fun clear() {
- data = null
- }
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt
index 790e9ca54..a4c1e6015 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt
@@ -41,7 +41,8 @@ class AccentAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = Accent.MAX
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccentViewHolder.new(parent)
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ AccentViewHolder.from(parent)
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
throw NotImplementedError()
@@ -75,13 +76,13 @@ class AccentAdapter(private val listener: ClickableListListener) :
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
}
- companion object {
- private val PAYLOAD_SELECTION_CHANGED = Any()
+ private companion object {
+ val PAYLOAD_SELECTION_CHANGED = Any()
}
}
/**
- * A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [new] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) :
@@ -93,13 +94,13 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(accent: Accent, listener: ClickableListListener) {
+ listener.bind(accent, this, binding.accent)
binding.accent.apply {
- setOnClickListener { listener.onClick(accent) }
- backgroundTintList = context.getColorCompat(accent.primary)
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
contentDescription = context.getString(accent.name)
TooltipCompat.setTooltipText(this, contentDescription)
+ backgroundTintList = context.getColorCompat(accent.primary)
}
}
@@ -124,6 +125,7 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
- fun new(parent: View) = AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
+ fun from(parent: View) =
+ AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt
index a90ce9720..2cb3c93d4 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt
@@ -20,6 +20,7 @@ package org.oxycblt.auxio.ui.accent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
+import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding
@@ -27,7 +28,6 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -38,7 +38,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class AccentCustomizeDialog :
ViewBindingDialogFragment(), ClickableListListener {
private var accentAdapter = AccentAdapter(this)
- private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
@@ -46,6 +45,7 @@ class AccentCustomizeDialog :
builder
.setTitle(R.string.set_accent)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
+ val settings = Settings(requireContext())
if (accentAdapter.selectedAccent == settings.accent) {
// Nothing to do.
return@setPositiveButton
@@ -66,7 +66,7 @@ class AccentCustomizeDialog :
if (savedInstanceState != null) {
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
} else {
- settings.accent
+ Settings(requireContext()).accent
})
}
@@ -80,12 +80,12 @@ class AccentCustomizeDialog :
binding.accentRecycler.adapter = null
}
- override fun onClick(item: Item) {
+ override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
check(item is Accent) { "Unexpected datatype: ${item::class.java}" }
accentAdapter.setSelectedAccent(item)
}
- companion object {
- private const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
+ private companion object {
+ const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
index d51a34530..638299bb9 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
@@ -18,6 +18,7 @@
package org.oxycblt.auxio.widgets
import android.content.Context
+import android.content.SharedPreferences
import android.graphics.Bitmap
import android.os.Build
import coil.request.ImageRequest
@@ -41,14 +42,15 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt)
*/
class WidgetComponent(private val context: Context) :
- PlaybackStateManager.Callback, Settings.Callback {
+ PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
private val playbackManager = PlaybackStateManager.getInstance()
- private val settings = Settings(context, this)
+ private val settings = Settings(context)
private val widgetProvider = WidgetProvider()
private val provider = BitmapProvider(context)
init {
- playbackManager.addCallback(this)
+ playbackManager.addListener(this)
+ settings.addListener(this)
}
/** Update [WidgetProvider] with the current playback state. */
@@ -104,9 +106,9 @@ class WidgetComponent(private val context: Context) :
/** Release this instance, preventing any further events from updating the widget instances. */
fun release() {
provider.release()
- settings.release()
+ settings.removeListener(this)
widgetProvider.reset(context)
- playbackManager.removeCallback(this)
+ playbackManager.removeListener(this)
}
// --- CALLBACKS ---
@@ -118,7 +120,8 @@ class WidgetComponent(private val context: Context) :
override fun onStateChanged(state: InternalPlayer.State) = update()
override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
- override fun onSettingChanged(key: String) {
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == context.getString(R.string.set_key_cover_mode) ||
key == context.getString(R.string.set_key_round_mode)) {
update()
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
index 2085db573..58f75b0a0 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
@@ -80,8 +80,8 @@ fun RemoteViews.setLayoutDirection(@IdRes viewId: Int, layoutDirection: Int) {
}
/**
- * Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with
- * an adaptive layout, in a version-compatible manner.
+ * Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with an
+ * adaptive layout, in a version-compatible manner.
* @param context [Context] required to backport adaptive layout behavior.
* @param component [ComponentName] of the app widget layout to update.
* @param views Mapping between different size classes and [RemoteViews] instances.
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
index ff59ab335..ca50a6edc 100644
--- a/app/src/main/res/layout/fragment_about.xml
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -9,7 +9,7 @@
android:transitionGroup="true"
tools:context=".settings.AboutFragment">
-
@@ -21,7 +21,7 @@
app:navigationIcon="@drawable/ic_back_24"
app:title="@string/lbl_about" />
-
+
-
@@ -37,7 +37,7 @@
app:tabGravity="start"
app:tabMode="scrollable" />
-
+
-
@@ -51,7 +51,7 @@
-
+
-
-
+
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 03b4e3668..3bc30c7de 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -268,4 +268,6 @@
Náhodně přehrát vybrané
Přehrát z žánru
Wiki
+ %1$s, %2$s
+ Obnovit
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 99d3cd98a..02eac70a1 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -259,4 +259,5 @@
%d ausgewählt
Vom Genre abspielen
Wiki
+ %1$s, %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index d645e7db3..519b81be3 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -128,12 +128,10 @@
Canciones cargadas: %d
- %d canción
- - %d canciones
- %d canciones
- %d álbum
- - %d álbumes
- %d álbumes
Tamaño
@@ -263,4 +261,5 @@
Reproducir los seleccionados
Reproducir desde el género
Wiki
+ %1$s, %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 57bf9eaa4..c9a88b9bf 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -258,4 +258,7 @@
Mescola selezionati
Riproduci selezionati
%d Selezionati
+ Riproduci dal genere
+ Wiki
+ %1$s, %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index 55fdaca67..e43b9b21c 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -256,4 +256,6 @@
Groti pasirinktą
Pasirinktas maišymas
Groti iš žanro
+ Viki
+ %1$s, %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 000000000..3a0906840
--- /dev/null
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 2e4539fc0..6ee2cd8e1 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -245,7 +245,6 @@
Mix
- %d artista
- - %d artistas
- %d artistas
Re-escanear músicas
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index e56e51268..5086dd21a 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -257,4 +257,5 @@
选中了 %d 首
按流派播放
Wiki
+ %1$s, %2$s
\ No newline at end of file
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index aca5a0088..2535c992e 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -7,6 +7,7 @@
%1$s • %2$s
%1$s • %2$s • %3$s
%d
+ %s - %s
%1$s/%2$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 68503a76b..65d845e60 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -60,9 +60,9 @@
Mixtapes
Mixtape
-
+
Mixes
-
+
Mix
@@ -125,10 +125,12 @@
OK
Cancel
+
+ Save
+
+ Reset
Add
-
- Save
State saved
@@ -341,6 +343,12 @@
+
+ %1$s, %2$s
+
%d Selected
@@ -362,7 +370,7 @@
Albums loaded: %d
Artists loaded: %d
Genres loaded: %d
-
+
Total duration: %s
diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt
index 49b52bfaf..8be30710b 100644
--- a/fastlane/metadata/android/cs/full_description.txt
+++ b/fastlane/metadata/android/cs/full_description.txt
@@ -1,4 +1,4 @@
-Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na systému Exoplayer má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu.
+Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na systému Exoplayer má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu.
Funkce
diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt
index 08a024ca4..62bec83d5 100644
--- a/fastlane/metadata/android/de/full_description.txt
+++ b/fastlane/metadata/android/de/full_description.txt
@@ -1,4 +1,4 @@
-Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und hat deshalb eine bessere Musik-Bibliotheks-Unterstützung und Qualität als andere Player, die die veralteten Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik.
+Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und hat deshalb eine bessere Musik-Bibliotheks-Unterstützung und Qualität als andere Player, die die veralteten Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik.
Funktionen
diff --git a/fastlane/metadata/android/en-US/changelogs/25.txt b/fastlane/metadata/android/en-US/changelogs/25.txt
new file mode 100644
index 000000000..5cc3dcac2
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/25.txt
@@ -0,0 +1,3 @@
+Auxio 3.0.0 massively improves the music library experience, with a new advanced music loader, a new unified artist model, and a new selection system that makes enqueueing music much simpler.
+This release adds some additional functionality and fixes a few regressions.
+For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.0.1.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index e55914587..196da8418 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,4 +1,4 @@
-Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of Exoplayer, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, It plays music.
+Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of Exoplayer, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, It plays music.
Features
diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt
index 1388ed06f..0f5338585 100644
--- a/fastlane/metadata/android/es-ES/full_description.txt
+++ b/fastlane/metadata/android/es-ES/full_description.txt
@@ -1,22 +1,22 @@
-Auxio es un reproductor de música local con una UI/UX rápida y confiable, pero sin las muchas funciones innecesarias que tienen otros reproductores. Auxio se basa en Exoplayer y, por lo tanto, tiene una mejor calidad y compatibilidad con la biblioteca de música que otros reproductores que utilizan funciones heredadas de Android. En resumen, Auxio reproduce música.
+Auxio es un reproductor de música local con una UI/UX rápida y confiable sin muchas funciones innecesarias que tienen otros reproductores de música. Basado en Exoplayer, Auxio tiene un mejor soporte para la biblioteca y una excelente calidad de sonido en comparación con otras aplicaciones que usan una función de Android obsoleta. En resumen, solo reproduce música.
Funciones
-- basado en ExoPlayer
-- interfaz de usuario simple, orientada al diseño de materiales
-- UX prefiere la simplicidad a los casos específicos
+- Reproductor basado en el sistema ExoPlayer
+- Interfaz de usuario receptiva de acuerdo con las últimas pautas de Diseño de materiales
+- UX agradable que prioriza la facilidad de uso sobre los casos extremos
- Comportamiento personalizable
-- Indexador de medios avanzado que favorece los metadatos correctos
-- Se admite el número de CD, varios artistas, tipos de publicación, fecha precisa/original, clasificación de etiquetas y tipo de publicación (Experimental)
-- sistema de artista avanzado que admite artistas y artistas de álbumes
-- admite tarjetas SD
+- Admite números de disco, múltiples artistas, tipos de lanzamiento,
+datos exactos/originales, clasificación de etiquetas y más
+- Sistema de artista avanzado que conecta artistas y artistas de álbumes
+- Gestión de carpetas compatible con tarjetas SD
- Almacenamiento confiable del estado de reproducción
- Compatibilidad total con ReplayGain (para archivos MP3, FLAC, OGG, OPUS y MP4)
-- Soporte de ecualizador externo (por ejemplo, Wavelet)
+- Soporte para reproductores externos (por ejemplo, Wavelet)
- Borde a borde
-- Soporte para tapas incrustadas
-- Buscar
-- Reproducción automática en auriculares
-- Widgets con estilo que ajustan su tamaño
-- completamente privado y fuera de línea
-- no hay portadas de álbumes redondeadas (a menos que las quieras. Entonces funciona)
+- Soporte para empaquetado integrado
+- Buscando función
+- Reproducción automática cuando los auriculares están conectados
+- Widgets con estilo que se adaptan automáticamente a su tamaño
+- Totalmente privado y fuera de línea
+- No hay portadas de álbumes redondeadas (a menos que las quieras. De lo contrario, están disponibles)
diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt
index 65a9f91ac..33bce72e6 100644
--- a/fastlane/metadata/android/it/full_description.txt
+++ b/fastlane/metadata/android/it/full_description.txt
@@ -1,21 +1,21 @@
-Auxio è un lettore di musica locale con una UI/UX veloce, affidabile e senza tanti fronzoli. Costruita attorno ad Exoplayer, Auxio fornisce una migliore esperienza di ascolto rispetto alle app che usano l'API nativa MediaPlayer. In breve, riproduce la musica.
+Auxio è un lettore di musica locale con UI/UX veloce, affidabile e semplice. Basato su Exoplayer, Auxio fornisce supporto e qualità di ascolto migliori rispetto ad altre app che usano funzioni di Android superate. In breve, riproduce la musica.
Funzioni
- Riproduzione basata su ExoPlayer
-- Elegante interfaccia in linea con le ultime guidelinea del Material Design
-- UI facile da usare
+- Elegante interfaccia in linea con le ultime novità del Material Design
+- UI di facile utilizzo
- Comportamento personalizzabile
-- Indicizzazione multimediale avanzata che prioritizza metadati corretti
-- Supporto a date precise, tags, e tipo di release (sperimentale)
-- Gestione delle cartelle
-- Persistenza stato di riproduzione affidabile
-- Supporto Replay Gain (su MP3, MP4, FLAC, OGG, e OPUS)
-- Supporto ad equalizzatori esterni (es. Wavelet)
+- Supporto a numero dischi, artisti multipli, tipo release, date originali/precise, tags ordinamento, etc.
+- Indicizzazione artisti e album artisti avanzata
+- Gestione delle cartelle informata dei cambiamenti della SD Card
+- Affidabilità della persistenza dello stato di riproduzione
+- Supporto Replay Gain (su MP3, FLAC, OGG, OPUS, e files MP4)
+- Supporto equalizzatori esterni (es. Wavelet)
- Edge-to-edge
- Supporto copertine integrate
- Funzionalità di ricerca
- Autoplay cuffie
- Widgets responsivi
- Completamente privato ed offline
-- No copertine dischi arrotondate (A meno che tu non le voglia.)
+- No copertine dischi arrotondate (a meno che tu non le voglia)
diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt
index da1e0136c..3e8a2ccf2 100644
--- a/fastlane/metadata/android/lt/full_description.txt
+++ b/fastlane/metadata/android/lt/full_description.txt
@@ -1,20 +1,21 @@
-„Auxio“ yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. „Auxio“ sukurta remiantis „Exoplayer“, todėl, palyginti su kitomis programomis, naudojančiomis „MediaPlayer“ API, „Auxio“ turi daug geresnę klausymo patirtį. Trumpai tariant, Jame groja muziką.
+„Auxio“ yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis „Exoplayer“, „Auxio“ turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias „Android“ funkcijas. Trumpai tariant, Jame groja muziką.
Funkcijos
- „ExoPlayer“ pagrįstas grojimas
- Sparti UI, sukurta pagal naujausias „Material Design“ gaires
-- Nuomonę atitinkanti UX, kurioje pirmenybė teikiama naudojimo paprastumui, o ne kraštutiniams atvejams
-- Tinkinama elgsena
-- Išplėstinis medijos indeksuotojas, kuris teikia pirmenybę teisingiems metaduomenims
-- Tikslių / Originalių datų, Rūšiavimo žymų ir Išleidimo Tipo palaikymas (Eksperimentinis)
+- Nuomonę turintis UX, kuriame prioritetas teikiamas naudojimo paprastumui, o ne kraštutiniam atvejui
+- Pasirinktas elgesys
+- Palaikomas diskų numerių, kelių atlikėjų, leidinių tipų palaikymas,
+tikslias/originalias datas, rūšiavimo žymas ir dar daugiau
+- Išplėstinė atlikėjų sistema, kuri suvienija atlikėjus ir albumų atlikėjus
- SD kortelių aplankų valdymas
- Patikimas grojimo būsenos išsaugojimas
-- Visiškas „ReplayGain“ palaikymas (MP3, MP4, FLAC, OGG ir OPUS)
-- Išorinio ekvalaizerio funkcija (tokiose programose kaip „Wavelet“)
+- Visiškas „ReplayGain“ palaikymas (MP3, MP4, FLAC, OGG, OPUS ir MP4 failus)
+- Išorinio ekvalaizerio funkcija (pvz., „Wavelet“)
- Krašto iki krašto
- Įterptųjų viršelių palaikymas
-- Paieškos Funkcija
+- Paieškos funkcija
- Automatinis ausinių grojimas
- Stilingi valdikliai, kurie automatiškai prisitaiko prie savo dydžio
- Visiškai privatus ir neprisijungęs
diff --git a/fastlane/metadata/android/nb-NO/short_description.txt b/fastlane/metadata/android/nb-NO/short_description.txt
new file mode 100644
index 000000000..96fd38129
--- /dev/null
+++ b/fastlane/metadata/android/nb-NO/short_description.txt
@@ -0,0 +1 @@
+Enkel, rasjonell musikkspiller
diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt
index 4f0737b84..cdc96866c 100644
--- a/fastlane/metadata/android/zh-CN/full_description.txt
+++ b/fastlane/metadata/android/zh-CN/full_description.txt
@@ -1,4 +1,4 @@
-Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于 Exoplayer 进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子。
+Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于 Exoplayer 进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子。
功能特性
diff --git a/prebuild.py b/prebuild.py
index 7aeb9eb11..09e632feb 100755
--- a/prebuild.py
+++ b/prebuild.py
@@ -19,13 +19,14 @@ import re
# WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION AND
# THE GRADLE DEPENDENCY. IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
-# EXO_VERSION = "2.18.1"
+# EXO_VERSION = "2.18.2"
FLAC_VERSION = "1.3.2"
-FATAL="\033[1;31m"
-WARN="\033[1;91m"
-INFO="\033[1;94m"
-OK="\033[1;92m"
+OK="\033[1;32m" # Bold green
+FATAL="\033[1;31m" # Bold red
+WARN="\033[1;33m" # Bold yellow
+RUN="\033[1;34m" # Bold blue
+INFO="\033[1m" # Bold white
NC="\033[0m"
# We do some shell scripting later on, so we can't support windows.
@@ -36,7 +37,7 @@ if system not in ["Linux", "Darwin"]:
sys.exit(1)
def sh(cmd):
- print(INFO + "execute: " + NC + cmd)
+ print(RUN + "execute: " + NC + cmd)
code = subprocess.call(["sh", "-c", "set -e; " + cmd])
if code != 0:
print(FATAL + "fatal:" + NC + " command failed with exit code " + str(code))
@@ -60,7 +61,7 @@ if os.getenv("ANDROID_HOME") is None and os.getenv("ANDROID_SDK_ROOT") is None:
"ANDROID_HOME/ANDROID_SDK_ROOT before continuing.")
sys.exit(1)
-ndk_path = os.getenv("NDK_PATH")
+ndk_path = os.getenv("ANDROID_NDK_HOME")
if ndk_path is None or not os.path.isfile(os.path.join(ndk_path, "ndk-build")):
# We don't have a proper path. Do some digging on the Android SDK directory
# to see if we can find it.
@@ -75,14 +76,15 @@ if ndk_path is None or not os.path.isfile(os.path.join(ndk_path, "ndk-build")):
candidates.append(entry.path)
if len(candidates) > 0:
- print(WARN + "warn:" + NC + " NDK_PATH was not set or invalid. multiple " +
+ print(WARN + "warn:" + NC + " ANDROID_NDK_HOME was not set or invalid. multiple " +
"candidates were found however:")
for i, candidate in enumerate(candidates):
print("[" + str(i) + "] " + candidate)
-
+ print(INFO + "info:" + NC + " NDK r21e is recommended for this script. Other " +
+ "NDKs may result in unexpected behavior.")
try:
ndk_path = candidates[int(input("enter the ndk to use [default 0]: "))]
- except:
+ except ValueError:
ndk_path = candidates[0]
else:
print(FATAL + "fatal:" + NC + " the android ndk was not installed at a " +