Merge pull request #310 from OxygenCobalt/dev

Version 3.0.1
This commit is contained in:
Alexander Capehart 2023-01-03 19:01:31 -07:00 committed by GitHub
commit 3e33510139
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
117 changed files with 1639 additions and 1491 deletions

View file

@ -72,6 +72,8 @@ body:
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash - `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
5. Copy and paste the output to this area of the issue. 5. Copy and paste the output to this area of the issue.
render: shell render: shell
validations:
required: true
- type: checkboxes - type: checkboxes
id: terms id: terms
attributes: attributes:

38
.github/workflows/android.yml vendored Normal file
View file

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

View file

@ -1,6 +1,26 @@
# Changelog # 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 ## 3.0.0
@ -12,7 +32,7 @@
- Added setting to hide "collaborator" artists - Added setting to hide "collaborator" artists
- Upgraded music ID management: - Upgraded music ID management:
- Added support for MusicBrainz IDs (MBIDs) - 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 - Genres now display a list of artists
- Added toggle to load non-music (Such as podcasts) - Added toggle to load non-music (Such as podcasts)
- Music loader now caches parsed metadata for faster load times - Music loader now caches parsed metadata for faster load times
@ -42,7 +62,6 @@ audio focus was lost
#### What's Changed #### What's Changed
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed. - 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" - "Use alternate notification action" is now "Custom notification action"
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers" - "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.0"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.1">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.0&color=0D5AF5"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.1&color=0D5AF5">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
@ -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). 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 ## License

View file

@ -12,8 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.0.0" versionName "3.0.1"
versionCode 24 versionCode 25
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
@ -121,3 +121,7 @@ spotless {
licenseHeaderFile("NOTICE") licenseHeaderFile("NOTICE")
} }
} }
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
}

View file

@ -21,5 +21,5 @@
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses. # 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 -dontobfuscate

View file

@ -116,7 +116,7 @@
<!-- </intent-filter>--> <!-- </intent-filter>-->
<!-- </receiver>--> <!-- </receiver>-->
<!-- "Now Playing" widget.. --> <!-- "Now Playing" widget. -->
<receiver <receiver
android:name=".widgets.WidgetProvider" android:name=".widgets.WidgetProvider"
android:exported="false" android:exported="false"

View file

@ -42,20 +42,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* *
* TODO: Custom language support * TODO: Custom language support
* *
* TODO: Add multi-select
*
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have) * TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
* *
* TODO: Migrate to material animation system * TODO: Migrate to material animation system
* *
* TODO: Unit testing * TODO: Unit testing
* *
* TODO: Standardize from/new usage
*
* TODO: Standardize companion object usage
*
* TODO: Standardize callback/listener naming.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -146,7 +138,7 @@ class MainActivity : AppCompatActivity() {
return true return true
} }
companion object { private companion object {
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED" const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
} }
} }

View file

@ -37,7 +37,6 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
@ -62,10 +61,8 @@ class MainFragment :
private val selectionModel: SelectionViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback() private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
private var initialNavDestinationChange = true private var initialNavDestinationChange = true
private val elevationNormal: Float by lifecycleObject { binding ->
binding.context.getDimen(R.dimen.elevation_normal)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -78,6 +75,8 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing // 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 } 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.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
binding.queueSheet.apply { binding.queueSheet.apply {

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song 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. * 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 data class DiscHeader(val disc: Int) : Item
/** /**
* A [Song] extension that adds information about it's file properties. * The properties of a [Song]'s file.
* @param song The internal song * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param properties The properties of the song file. Null if parsing is ongoing. * @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?) { data class SongProperties(
/** val bitrateKbps: Int?,
* The properties of a [Song]'s file. val sampleRateHz: Int?,
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. val resolvedMimeType: MimeType
* @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
)
}

View file

@ -31,13 +31,13 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field import java.lang.reflect.Field
import org.oxycblt.auxio.R 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.getInteger
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
/** /**
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes * An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* beyond it's first item. * 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, * 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 * 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 class DetailAppBarLayout
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : 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 titleView: TextView? = null
private var recycler: RecyclerView? = null private var recycler: RecyclerView? = null
@ -166,8 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
} }
companion object { private companion object {
private val TOOLBAR_TITLE_TEXT_FIELD: Field by val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
lazyReflectedField(Toolbar::class, "mTitleTextView")
} }
} }

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort 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.settings.Settings
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class DetailViewModel(application: Application) : class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback { AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settings = Settings(application) private val settings = Settings(application)
@ -59,15 +59,15 @@ class DetailViewModel(application: Application) :
// --- SONG --- // --- SONG ---
private val _currentSong = MutableStateFlow<DetailSong?>(null) private val _currentSong = MutableStateFlow<Song?>(null)
/** /** The current [Song] to display. Null if there is nothing to show. */
* The current [DetailSong] to display. Null if there is nothing to show. val currentSong: StateFlow<Song?>
*
* TODO: De-couple Song and Properties?
*/
val currentSong: StateFlow<DetailSong?>
get() = _currentSong get() = _currentSong
private val _songProperties = MutableStateFlow<SongProperties?>(null)
/** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
val songProperties: StateFlow<SongProperties?> = _songProperties
// --- ALBUM --- // --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
@ -130,11 +130,11 @@ class DetailViewModel(application: Application) :
} }
init { init {
musicStore.addCallback(this) musicStore.addListener(this)
} }
override fun onCleared() { override fun onCleared() {
musicStore.removeCallback(this) musicStore.removeListener(this)
} }
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
@ -149,13 +149,8 @@ class DetailViewModel(application: Application) :
val song = currentSong.value val song = currentSong.value
if (song != null) { if (song != null) {
val newSong = library.sanitize(song.song) _currentSong.value = library.sanitize(song)?.also(::loadProperties)
if (newSong != null) { logD("Updated song to ${currentSong.value}")
loadDetailSong(newSong)
} else {
_currentSong.value = null
}
logD("Updated song to $newSong")
} }
val album = currentAlbum.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 * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong]. * and [songProperties] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSongUid(uid: Music.UID) { fun setSongUid(uid: Music.UID) {
if (_currentSong.value?.run { song.uid } == uid) { if (_currentSong.value?.uid == uid) {
// Nothing to do. // Nothing to do.
return return
} }
logD("Opening Song [uid: $uid]") logD("Opening Song [uid: $uid]")
loadDetailSong(requireMusic(uid)) _currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
} }
/** /**
@ -202,7 +197,7 @@ class DetailViewModel(application: Application) :
return return
} }
logD("Opening Album [uid: $uid]") logD("Opening Album [uid: $uid]")
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) } _currentAlbum.value = requireMusic<Album>(uid)?.also(::refreshAlbumList)
} }
/** /**
@ -216,7 +211,7 @@ class DetailViewModel(application: Application) :
return return
} }
logD("Opening Artist [uid: $uid]") logD("Opening Artist [uid: $uid]")
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) } _currentArtist.value = requireMusic<Artist>(uid)?.also(::refreshArtistList)
} }
/** /**
@ -230,29 +225,29 @@ class DetailViewModel(application: Application) :
return return
} }
logD("Opening Genre [uid: $uid]") logD("Opening Genre [uid: $uid]")
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) } _currentGenre.value = requireMusic<Genre>(uid)?.also(::refreshGenreList)
} }
private fun <T : Music> requireMusic(uid: Music.UID): T = private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
/** /**
* 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. * @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. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
_currentSong.value = DetailSong(song, null) _songProperties.value = null
currentSongJob = currentSongJob =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val info = loadProperties(song) val properties = this@DetailViewModel.loadPropertiesImpl(song)
yield() 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 // 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 // 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. // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
@ -266,7 +261,7 @@ class DetailViewModel(application: Application) :
// that we can show. // that we can show.
logW("Unable to extract song attributes.") logW("Unable to extract song attributes.")
logW(e.stackTraceToString()) 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 // 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) MimeType(song.mimeType.fromExtension, formatMimeType)
} }
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType) return SongProperties(bitrate, sampleRate, resolvedMimeType)
} }
private fun refreshAlbumList(album: Album) { private fun refreshAlbumList(album: Album) {

View file

@ -26,6 +26,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.androidActivityViewModels
@ -53,10 +54,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid) 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) { if (song == null) {
// Song we were showing no longer exists. // Song we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
@ -64,28 +65,28 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
} }
val binding = requireBinding() val binding = requireBinding()
if (song.properties != null) { if (properties != null) {
// Finished loading Song properties, populate and show the list of Song information. // Finished loading Song properties, populate and show the list of Song information.
binding.detailLoading.isInvisible = true binding.detailLoading.isInvisible = true
binding.detailContainer.isInvisible = false binding.detailContainer.isInvisible = false
val context = requireContext() val context = requireContext()
binding.detailFileName.setText(song.song.path.name) binding.detailFileName.setText(song.path.name)
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context)) binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context)) binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size)) binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true)) binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
if (song.properties.bitrateKbps != null) { if (properties.bitrateKbps != null) {
binding.detailBitrate.setText( binding.detailBitrate.setText(
getString(R.string.fmt_bitrate, song.properties.bitrateKbps)) getString(R.string.fmt_bitrate, properties.bitrateKbps))
} else { } else {
binding.detailBitrate.setText(R.string.def_bitrate) binding.detailBitrate.setText(R.string.def_bitrate)
} }
if (song.properties.sampleRateHz != null) { if (properties.sampleRateHz != null) {
binding.detailSampleRate.setText( binding.detailSampleRate.setText(
getString(R.string.fmt_sample_rate, song.properties.sampleRateHz)) getString(R.string.fmt_sample_rate, properties.sampleRateHz))
} else { } else {
binding.detailSampleRate.setText(R.string.def_sample_rate) binding.detailSampleRate.setText(R.string.def_sample_rate)
} }

View file

@ -67,9 +67,9 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.new(parent) AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.new(parent) DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.new(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) 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 return super.isItemFullWidth(position) || item is Album || item is DiscHeader
} }
companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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 // Date, song count, and duration map to the info text
binding.detailInfo.apply { binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information // 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 songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true) val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration) 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** 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) = override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) && oldItem.areArtistContentsTheSame(newItem) &&
oldItem.date == newItem.date && oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs && oldItem.durationMs == newItem.durationMs &&
oldItem.type == newItem.type 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 * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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. * @param listener A [SelectableListListener] to bind interactions to.
*/ */
fun bind(song: Song, listener: SelectableListListener) { fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songTrack.apply { binding.songTrack.apply {
if (song.track != null) { 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater)) AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */

View file

@ -54,9 +54,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.new(parent) ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.new(parent) ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.new(parent) ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -76,9 +76,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
return super.isItemFullWidth(position) || item is Artist return super.isItemFullWidth(position) || item is Artist
} }
companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(album: Album, listener: SelectableListListener) { fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu) listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album) binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context) binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information // 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) { 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() { object : SimpleItemCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) = 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(song: Song, listener: SelectableListListener) { fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.album.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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */

View file

@ -57,8 +57,8 @@ abstract class DetailAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent) HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.new(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
@ -109,7 +109,7 @@ abstract class DetailAdapter(
fun onOpenSortMenu(anchor: View) fun onOpenSortMenu(anchor: View)
} }
companion object { protected companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
@ -128,7 +128,7 @@ abstract class DetailAdapter(
/** /**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater)) SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */

View file

@ -54,9 +54,9 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent) GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent) SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -75,7 +75,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return super.isItemFullWidth(position) || item is Genre return super.isItemFullWidth(position) || item is Genre
} }
companion object { private companion object {
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { 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. * create an instance.
* @author Alexander Capehart (OxygenCobalt) * @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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */

View file

@ -49,14 +49,7 @@ import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.*
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.system.Indexer import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
@ -72,17 +65,7 @@ class HomeFragment :
private val homeModel: HomeViewModel by androidActivityViewModels() private val homeModel: HomeViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
// lifecycleObject builds this in the creation step, so doing this is okay.
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.refresh()
}
}
private val sortItem: MenuItem by lifecycleObject { binding ->
binding.homeToolbar.menu.findItem(R.id.submenu_sorting)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -105,6 +88,12 @@ class HomeFragment :
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
// Have to set up the permission launcher before the view is shown
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.refresh()
}
// --- UI SETUP --- // --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(this) binding.homeToolbar.setOnMenuItemClickListener(this)
@ -171,6 +160,7 @@ class HomeFragment :
override fun onDestroyBinding(binding: FragmentHomeBinding) { override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null) 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) val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) { for (option in sortMenu) {
// Check the ascending option and corresponding sort option to align with // Check the ascending option and corresponding sort option to align with
// the current sort of the tab. // the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId || 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 option.isChecked = true
} }
@ -303,7 +295,13 @@ class HomeFragment :
// Update the scrolling view in AppBarLayout to align with the current tab's // 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 // scrolling state. This prevents the lift state from being confused as one
// goes between different tabs. // 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) { private fun handleRecreate(recreate: Boolean) {
@ -321,9 +319,12 @@ class HomeFragment :
} }
private fun updateIndexerState(state: Indexer.State?) { 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() val binding = requireBinding()
when (state) { 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) is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
null -> { null -> {
logD("Indexer is in indeterminate state") logD("Indexer is in indeterminate state")
@ -332,53 +333,56 @@ class HomeFragment :
} }
} }
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) { private fun setupCompleteState(
if (response is Indexer.Response.Ok) { binding: FragmentHomeBinding,
result: Result<MusicStore.Library>
) {
if (result.isSuccess) {
logD("Received ok response") logD("Received ok response")
binding.homeFab.show() binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE binding.homeIndexingContainer.visibility = View.INVISIBLE
} else { } else {
logD("Received non-ok response") logD("Received non-ok response")
val context = requireContext() val context = requireContext()
val throwable = unlikelyToBeNull(result.exceptionOrNull())
binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE binding.homeIndexingProgress.visibility = View.INVISIBLE
when (response) { when (throwable) {
is Indexer.Response.Err -> { is Indexer.NoPermissionException -> {
logD("Updating UI to Response.Err state") logD("Updating UI to permission request 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")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher. // Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant) text = context.getString(R.string.lbl_grant)
setOnClickListener { 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() val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) && if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) { selected.isNotEmpty()) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar") logD("Significant selection occurred, expanding AppBar")
// Significant enough change where we want to expand the RecyclerView binding.homeAppbar.expandWithScrollingRecycler()
binding.homeAppbar.expandWithRecycler(
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
} }
} }
@ -457,20 +460,6 @@ class HomeFragment :
reenterTransition = MaterialSharedAxis(axis, false) 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. * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
* @param tabs The current tab configuration. This will define the [Fragment]s created. * @param tabs The current tab configuration. This will define the [Fragment]s created.
@ -493,12 +482,10 @@ class HomeFragment :
} }
} }
companion object { private companion object {
private val VP_RECYCLER_FIELD: Field by val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
lazyReflectedField(ViewPager2::class, "mRecyclerView") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
private val RV_TOUCH_SLOP_FIELD: Field by const val KEY_LAST_TRANSITION_AXIS =
lazyReflectedField(RecyclerView::class, "mTouchSlop")
private const val KEY_LAST_TRANSITION_AXIS =
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
} }
} }

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.home package org.oxycblt.auxio.home
import android.app.Application import android.app.Application
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,9 +40,11 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class HomeViewModel(application: Application) : class HomeViewModel(application: Application) :
AndroidViewModel(application), Settings.Callback, MusicStore.Callback { AndroidViewModel(application),
MusicStore.Listener,
SharedPreferences.OnSharedPreferenceChangeListener {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settings = Settings(application, this) private val settings = Settings(application)
private val _songsList = MutableStateFlow(listOf<Song>()) private val _songsList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** 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<Boolean> = _isFastScrolling val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init { init {
musicStore.addCallback(this) musicStore.addListener(this)
settings.addListener(this)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeCallback(this) musicStore.removeListener(this)
settings.release() settings.removeListener(this)
} }
override fun onLibraryChanged(library: MusicStore.Library?) { 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) { when (key) {
context.getString(R.string.set_key_lib_tabs) -> { context.getString(R.string.set_key_lib_tabs) -> {
// Tabs changed, update the current tabs and set up a re-create event. // Tabs changed, update the current tabs and set up a re-create event.

View file

@ -169,8 +169,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
} }
} }
companion object { private companion object {
// Pre-calculate sqrt(2) // Pre-calculate sqrt(2)
private const val SQRT2 = 1.4142135f const val SQRT2 = 1.4142135f
} }
} }

View file

@ -71,26 +71,6 @@ class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { 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 // Thumb
private val thumbView = private val thumbView =
View(context).apply { View(context).apply {
@ -524,7 +504,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
else -> 0 else -> 0
} }
companion object { /** An interface to provide text to use in the popup when fast-scrolling. */
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500 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
} }
} }

View file

@ -94,8 +94,8 @@ class AlbumListFragment :
is Sort.Mode.ByArtist -> is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() } album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext()) is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
@ -152,7 +152,7 @@ class AlbumListFragment :
get() = differ.currentList get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.new(parent) AlbumViewHolder.from(parent)
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) { override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener) holder.bind(differ.currentList[position], listener)

View file

@ -127,7 +127,7 @@ class ArtistListFragment :
get() = differ.currentList get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.new(parent) ArtistViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener) holder.bind(differ.currentList[position], listener)

View file

@ -126,7 +126,7 @@ class GenreListFragment :
get() = differ.currentList get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.new(parent) GenreViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) { override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener) holder.bind(differ.currentList[position], listener)

View file

@ -103,7 +103,7 @@ class SongListFragment :
song.album.collationKey?.run { sourceString.first().uppercase() } song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
@ -166,7 +166,7 @@ class SongListFragment :
get() = differ.currentList get() = differ.currentList
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.new(parent) SongViewHolder.from(parent)
override fun onBindViewHolder(holder: SongViewHolder, position: Int) { override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener) holder.bind(differ.currentList[position], listener)

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.home.tabs package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE 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. * @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt) * @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. * 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. * @param mode The type of list in the home view this instance corresponds to.

View file

@ -18,27 +18,28 @@
package org.oxycblt.auxio.home.tabs package org.oxycblt.auxio.home.tabs
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. * 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<TabViewHolder>() { class TabAdapter(private val listener: EditableListListener) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */ /** The current array of [Tab]s. */
var tabs = arrayOf<Tab>() var tabs = arrayOf<Tab>()
private set private set
override fun getItemCount() = tabs.size 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) { override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position], listener) holder.bind(tabs[position], listener)
} }
@ -75,30 +76,13 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewH
notifyItemMoved(a, b) notifyItemMoved(a, b)
} }
/** A listener for interactions specific to tab configuration. */ private companion object {
interface Listener { val PAYLOAD_TAB_CHANGED = Any()
/**
* Called when a tab is clicked, requesting that the visibility should be inverted (i.e
* Visible -> 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()
} }
} }
/** /**
* 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabViewHolder private constructor(private val binding: ItemTabBinding) : 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. * Bind new data to this instance.
* @param tab The new [Tab] to bind. * @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") @SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: TabAdapter.Listener) { fun bind(tab: Tab, listener: EditableListListener) {
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) } listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply { binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode // Update the CheckBox name to align with the mode
setText( 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) // the tab data since they are in the same data structure (Tab)
isChecked = tab is Tab.Visible 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 { companion object {
@ -143,6 +117,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater)) fun from(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -25,23 +25,19 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding 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.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener { class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener {
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val tabAdapter = TabAdapter(this) private val tabAdapter = TabAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject { private var touchHelper: ItemTouchHelper? = null
ItemTouchHelper(TabDragCallback(tabAdapter))
}
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@ -50,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
.setTitle(R.string.set_lib_tabs) .setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes") logD("Committing tab changes")
settings.libTabs = tabAdapter.tabs Settings(requireContext()).libTabs = tabAdapter.tabs
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { 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. // Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) { if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS)) val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
@ -69,7 +65,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
tabAdapter.submitTabs(tabs) tabAdapter.submitTabs(tabs)
binding.tabRecycler.apply { binding.tabRecycler.apply {
adapter = tabAdapter adapter = tabAdapter
touchHelper.attachToRecyclerView(this) touchHelper =
ItemTouchHelper(TabDragCallback(tabAdapter)).also { it.attachToRecyclerView(this) }
} }
} }
@ -84,12 +81,11 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
binding.tabRecycler.adapter = null binding.tabRecycler.adapter = null
} }
override fun onToggleVisibility(tabMode: MusicMode) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
logD("Toggling tab $tabMode") check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
// We will need the exact index of the tab to update on in order to // We will need the exact index of the tab to update on in order to
// notify the adapter of the change. // 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] val tab = tabAdapter.tabs[index]
tabAdapter.setTab( tabAdapter.setTab(
index, index,
@ -105,10 +101,10 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
} }
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder) requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
} }
companion object { private companion object {
private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS" const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
} }
} }

View file

@ -65,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float private val cornerRadius: Float
init { 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") @SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) 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 // 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/ // Playback indicator should sit above the inner StyledImageView and custom view/
addView(playbackIndicatorView) 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( addView(
selectionIndicatorView, selectionIndicatorView,
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {

View file

@ -177,6 +177,8 @@ object Covers {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? { private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
// Eliminate any chance that this blocking call might mess up the loading process // 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)
}
} }
} }

View file

@ -22,6 +22,7 @@ import android.view.View
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -53,7 +54,7 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
*/ */
abstract fun onRealClick(music: Music) 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}" } check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
if (selectionModel.selected.value.isNotEmpty()) { if (selectionModel.selected.value.isNotEmpty()) {
// Map clicking an item to selecting an item when items are already selected. // Map clicking an item to selecting an item when items are already selected.

View file

@ -17,8 +17,8 @@
package org.oxycblt.auxio.list package org.oxycblt.auxio.list
import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.Button
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
/** /**
@ -26,13 +26,63 @@ import androidx.recyclerview.widget.RecyclerView
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface ClickableListListener { 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. * Called when an [Item] in the list is clicked.
* @param item The [Item] that was 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. * 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 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) { fun bind(
viewHolder.itemView.apply { item: Item,
// Map clicks to the click listener. viewHolder: RecyclerView.ViewHolder,
setOnClickListener { onClick(item) } bodyView: View = viewHolder.itemView,
// Map long clicks to the selection listener. menuButton: View
setOnLongClickListener { ) {
onSelect(item) bind(item, viewHolder, bodyView)
true // Map long clicks to the selection listener.
} bodyView.setOnLongClickListener {
onSelect(item)
true
} }
// Map the menu button to the menu opening listener. // Map the menu button to the menu opening listener.
menuButton.setOnClickListener { onOpenMenu(item, it) } menuButton.setOnClickListener { onOpenMenu(item, it) }

View file

@ -115,7 +115,7 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
} }
companion object { private companion object {
private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any() val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
} }
} }

View file

@ -77,7 +77,7 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
abstract fun updateSelectionIndicator(isSelected: Boolean) abstract fun updateSelectionIndicator(isSelected: Boolean)
} }
companion object { private companion object {
private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any() val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
} }
} }

View file

@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongViewHolder private constructor(private val binding: ItemSongBinding) : 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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(song: Song, listener: SelectableListListener) { fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) : 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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(album: Album, listener: SelectableListListener) { fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu) listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album) binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context) binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text = album.resolveArtistContents(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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) : 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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(artist: Artist, listener: SelectableListListener) { fun bind(artist: Artist, listener: SelectableListListener) {
listener.bind(this, artist, binding.parentMenu) listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist) binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context) binding.parentName.text = artist.resolveName(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
@ -175,7 +175,8 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreViewHolder private constructor(private val binding: ItemParentBinding) : 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. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
fun bind(genre: Genre, listener: SelectableListListener) { fun bind(genre: Genre, listener: SelectableListListener) {
listener.bind(this, genre, binding.parentMenu) listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre) binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context) binding.parentName.text = genre.resolveName(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
@ -228,7 +229,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @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. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.*
* A [ViewModel] that manages the current selection. * A [ViewModel] that manages the current selection.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SelectionViewModel : ViewModel(), MusicStore.Callback { class SelectionViewModel : ViewModel(), MusicStore.Listener {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val _selected = MutableStateFlow(listOf<Music>()) private val _selected = MutableStateFlow(listOf<Music>())
@ -35,7 +35,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
get() = _selected get() = _selected
init { init {
musicStore.addCallback(this) musicStore.addListener(this)
} }
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
@ -58,7 +58,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeCallback(this) musicStore.removeListener(this)
} }
/** /**

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>) : Comparable<Date> {
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<Range> {
/**
* 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<Date>): 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<Int>): Date? {
val validated = mutableListOf<Int>()
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<Int>, dst: MutableList<Int>) {
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)
}
}
}

View file

@ -24,20 +24,16 @@ import android.os.Parcelable
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator import java.text.Collator
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.extractor.parseId3GenreNames import org.oxycblt.auxio.music.filesystem.*
import org.oxycblt.auxio.music.extractor.parseMultiValue import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.extractor.toUuidOrNull import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -114,6 +110,27 @@ sealed class Music : Item {
return COLLATOR.getCollationKey(sortName) 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<Music>): 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 // Note: We solely use the UID in comparisons so that certain items that differ in all
// but UID are treated differently. // 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]. */ /** 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. * Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter. * @param context [Context] required for [resolveName]. formatter.
*/ */
fun resolveArtistContents(context: Context) = fun resolveArtistContents(context: Context) = resolveNames(context, artists)
// TODO Internationalize the list
artists.joinToString { it.resolveName(context) }
/** /**
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only * 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. * Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName]. * @param context [Context] required for [resolveName].
*/ */
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) } fun resolveGenreContents(context: Context) = resolveNames(context, genres)
// --- INTERNAL FIELDS --- // --- INTERNAL FIELDS ---
@ -504,7 +519,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
for (i in _artists.indices) { for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with // Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata. // 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 newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
val other = _artists[newIdx] val other = _artists[newIdx]
_artists[newIdx] = _artists[i] _artists[newIdx] = _artists[i]
@ -610,11 +624,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
override val collationKey = makeCollationKeyImpl() override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
/** /** The [Date.Range] that [Song]s in the [Album] were released. */
* The earliest [Date] this album was released. Will be null if no valid date was present in the val dates = Date.Range.from(songs.mapNotNull { it.date })
* metadata of any [Song]
*/
val date: Date? // TODO: Date ranges?
/** /**
* The [Type] of this album, signifying the type of release it actually is. Defaults to * 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<Song>) : MusicParent(
val dateAdded: Long val dateAdded: Long
init { init {
var earliestDate: Date? = null
var totalDuration: Long = 0 var totalDuration: Long = 0
var earliestDateAdded: Long = Long.MAX_VALUE var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency. // Do linking and value generation in the same loop for efficiency.
for (song in songs) { for (song in songs) {
song._link(this) 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) { if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded earliestDateAdded = song.dateAdded
} }
totalDuration += song.durationMs totalDuration += song.durationMs
} }
date = earliestDate
durationMs = totalDuration durationMs = totalDuration
dateAdded = earliestDateAdded dateAdded = earliestDateAdded
} }
@ -676,7 +674,7 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
* Resolves one or more [Artist]s into a single piece of human-readable names. * Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. * @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 * 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<Music>) : MusicP
* Resolves one or more [Genre]s into a single piece of human-readable names. * Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. * @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 * 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<Song>) :
} }
} }
/**
* 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<Int>) : Comparable<Date> {
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<Int>): Date? {
val validated = mutableListOf<Int>()
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<Int>, dst: MutableList<Int>) {
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 --- // --- 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]. * Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed. * @param string The [String] to hash. If null, it will not be hashed.

View file

@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery import org.oxycblt.auxio.music.filesystem.useQuery
/** /**
* A repository granting access to the music library.. * A repository granting access to the music library..
@ -33,42 +33,43 @@ import org.oxycblt.auxio.music.storage.useQuery
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicStore private constructor() { class MusicStore private constructor() {
private val callbacks = mutableListOf<Callback>() private val listeners = mutableListOf<Listener>()
/** /**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This * 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 * can change, so it's highly recommended to not access this directly and instead rely on
* [Callback]. * [Listener].
*/ */
@Volatile
var library: Library? = null var library: Library? = null
set(value) { set(value) {
field = value field = value
for (callback in callbacks) { for (callback in listeners) {
callback.onLibraryChanged(library) callback.onLibraryChanged(library)
} }
} }
/** /**
* Add a [Callback] to this instance. This can be used to receive changes in the music library. * Add a [Listener] 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. * Will invoke all [Listener] methods to initialize the instance with the current state.
* @param callback The [Callback] to add. * @param listener The [Listener] to add.
* @see Callback * @see Listener
*/ */
@Synchronized @Synchronized
fun addCallback(callback: Callback) { fun addListener(listener: Listener) {
callback.onLibraryChanged(library) listener.onLibraryChanged(library)
callbacks.add(callback) listeners.add(listener)
} }
/** /**
* Remove a [Callback] from this instance, preventing it from recieving any further updates. * Remove a [Listener] 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 * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place. * the first place.
* @see Callback * @see Listener
*/ */
@Synchronized @Synchronized
fun removeCallback(callback: Callback) { fun removeListener(listener: Listener) {
callbacks.remove(callback) listeners.remove(listener)
} }
/** /**
@ -167,7 +168,7 @@ class MusicStore private constructor() {
} }
/** A listener for changes in the music library. */ /** A listener for changes in the music library. */
interface Callback { interface Listener {
/** /**
* Called when the current [Library] has changed. * Called when the current [Library] has changed.
* @param library The new [Library], or null if no [Library] has been loaded yet. * @param library The new [Library], or null if no [Library] has been loaded yet.

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.system.Indexer
* A [ViewModel] providing data specific to the music loading process. * A [ViewModel] providing data specific to the music loading process.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicViewModel : ViewModel(), Indexer.Callback { class MusicViewModel : ViewModel(), Indexer.Listener {
private val indexer = Indexer.getInstance() private val indexer = Indexer.getInstance()
private val _indexerState = MutableStateFlow<Indexer.State?>(null) private val _indexerState = MutableStateFlow<Indexer.State?>(null)
@ -39,18 +39,18 @@ class MusicViewModel : ViewModel(), Indexer.Callback {
get() = _statistics get() = _statistics
init { init {
indexer.registerCallback(this) indexer.registerListener(this)
} }
override fun onCleared() { override fun onCleared() {
indexer.unregisterCallback(this) indexer.unregisterListener(this)
} }
override fun onIndexerStateChanged(state: Indexer.State?) { override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = 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. // New state is a completed library, update the statistics values.
val library = state.response.library val library = state.result.getOrNull() ?: return
_statistics.value = _statistics.value =
Statistics( Statistics(
library.songs.size, library.songs.size,

View file

@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.album.date }, compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
@ -241,14 +241,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.date }, compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM)) compareBy(BasicComparator.ALBUM))
} }
/** /**
* Sort by the [Date] of an item. Only available for [Song] and [Album]. * Sort by the [Date] of an item. Only available for [Song] and [Album].
* @see Song.date * @see Song.date
* @see Album.date * @see Album.dates
*/ */
object ByDate : Mode() { object ByDate : Mode() {
override val intCode: Int override val intCode: Int
@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date }, compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
@ -267,7 +267,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE) { it.date }, compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM)) 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. * Sort by the date an item was added. Only supported by [Song]s and [Album]s.
* @see Song.dateAdded * @see Song.dateAdded
* @see Album.date * @see Album.dates
*/ */
object ByDateAdded : Mode() { object ByDateAdded : Mode() {
override val intCode: Int override val intCode: Int
@ -543,8 +543,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val INT = NullableComparator<Int>() val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */ /** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>() val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Date]s. */ /** A re-usable instance configured for [Date.Range]s. */
val DATE = NullableComparator<Date>() val DATE_RANGE = NullableComparator<Date.Range>()
} }
} }

View file

@ -23,7 +23,10 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song 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.* import org.oxycblt.auxio.util.*
/** /**
@ -278,7 +281,7 @@ private class CacheDatabase(context: Context) :
raw.track = cursor.getIntOrNull(trackIndex) raw.track = cursor.getIntOrNull(trackIndex)
raw.disc = cursor.getIntOrNull(discIndex) 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.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex) 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 * @return A list of strings corresponding to the delimited values present within the original
* string. Escaped delimiters are converted back into their normal forms. * string. Escaped delimiters are converted back into their normal forms.
*/ */
private fun String.parseSQLMultiValue() = private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
splitEscaped { it == ';' }
/** Defines the columns used in this database. */ /** Defines the columns used in this database. */
private object Columns { private object Columns {

View file

@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
enum class ExtractionResult { enum class ExtractionResult {
/** A raw song was successfully extracted from the cache. */ /** A raw song was successfully extracted from the cache. */
CACHED, CACHED,
/** A raw song was successfully extracted from parsing it's file. */ /** A raw song was successfully extracted from parsing it's file. */
PARSED, PARSED,
/** A raw song could not be parsed. */ /** A raw song could not be parsed. */
NONE NONE
} }

View file

@ -27,17 +27,20 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import java.io.File import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat import org.oxycblt.auxio.music.filesystem.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery import org.oxycblt.auxio.music.filesystem.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery import org.oxycblt.auxio.music.filesystem.useQuery
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD 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 * 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 // 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. // 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. // 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 // 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 // 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 // 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) } 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 * The base selector that works across all versions of android. Does not exclude
* directories. * 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 * 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. * versions that Auxio supports.
*/ */
@Suppress("InlinedApi") @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 * 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. * 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 // 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 // N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it. // total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it } cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = 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()

View file

@ -21,12 +21,10 @@ import android.content.Context
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever 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.Date
import org.oxycblt.auxio.music.Song 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.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -116,8 +114,8 @@ class MetadataExtractor(
} }
} }
companion object { private companion object {
private const val TASK_CAPACITY = 8 const val TASK_CAPACITY = 8
} }
} }
@ -128,7 +126,6 @@ class MetadataExtractor(
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Task(context: Context, private val raw: Song.Raw) { 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 // 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 // (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely. // 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? { fun get(): Song.Raw? {
if (!future.isDone) { if (!future.isDone) {
// Not done yet, nothing to do.
return null return null
} }
@ -162,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
val metadata = format.metadata val metadata = format.metadata
if (metadata != null) { if (metadata != null) {
populateWithMetadata(metadata) val tags = Tags(metadata)
populateWithId3v2(tags.id3v2)
populateWithVorbis(tags.vorbis)
} else { } else {
logD("No metadata could be extracted for ${raw.name}") logD("No metadata could be extracted for ${raw.name}")
} }
@ -170,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
return 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<String, List<String>>()
val vorbisTags = mutableMapOf<String, MutableList<String>>()
// 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. * 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 * @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<String, List<String>>) { private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song // 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["TIT2"]?.let { raw.name = it[0] }
textFrames["TSOT"]?.let { raw.sortName = it[0] } textFrames["TSOT"]?.let { raw.sortName = it[0] }
// Track. Only parse out the track number and ignore the total tracks value. // 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. // 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 // 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 // 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 // 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1 // 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type // 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { get(0).parseTimestamp() } (textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() } ?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() } ?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames)) ?: parseId3v23Date(textFrames))
?.let { raw.date = it } ?.let { raw.date = it }
// Album // 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["TALB"]?.let { raw.albumName = it[0] }
textFrames["TSOA"]?.let { raw.albumSortName = 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 raw.albumTypes = it
} }
// Artist // 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["TPE1"]?.let { raw.artistNames = it }
textFrames["TSOP"]?.let { raw.artistSortNames = it } textFrames["TSOP"]?.let { raw.artistSortNames = it }
// Album artist // 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["TPE2"]?.let { raw.albumArtistNames = it }
textFrames["TSO2"]?.let { raw.albumArtistSortNames = 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 // Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present. // is present.
val year = val year =
textFrames["TORY"]?.run { get(0).toIntOrNull() } textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"] val tdat = textFrames["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) { 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<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // Song
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] } comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
comments["TITLE"]?.let { raw.name = it[0] } comments["title"]?.let { raw.name = it[0] }
comments["TITLESORT"]?.let { raw.sortName = it[0] } comments["titlesort"]?.let { raw.sortName = it[0] }
// Track. The total tracks value is in a different comment, so we can just // Track. The total tracks value is in a different comment, so we can just
// convert the entirety of this comment into a number. // 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 // Disc. The total discs value is in a different comment, so we can just
// convert the entirety of this comment into a number. // 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 // Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // 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 // 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 // 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!) // date tag that android supports, so it must be 15 years old or more!)
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() } (comments["originaldate"]?.run { Date.from(first()) }
?: comments["DATE"]?.run { get(0).parseTimestamp() } ?: comments["date"]?.run { Date.from(first()) }
?: comments["YEAR"]?.run { get(0).parseYear() }) ?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
?.let { raw.date = it } ?.let { raw.date = it }
// Album // Album
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] } comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
comments["ALBUM"]?.let { raw.albumName = it[0] } comments["album"]?.let { raw.albumName = it[0] }
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] } comments["albumsort"]?.let { raw.albumSortName = it[0] }
comments["RELEASETYPE"]?.let { raw.albumTypes = it } comments["releasetype"]?.let { raw.albumTypes = it }
// Artist // Artist
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it } comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
comments["ARTIST"]?.let { raw.artistNames = it } comments["artist"]?.let { raw.artistNames = it }
comments["ARTISTSORT"]?.let { raw.artistSortNames = it } comments["artistsort"]?.let { raw.artistSortNames = it }
// Album artist // Album artist
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it } comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it } comments["albumartist"]?.let { raw.albumArtistNames = it }
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it } comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
// Genre // Genre
comments["GENRE"]?.let { raw.genreNames = it } 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())
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, List<String>>()
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
val id3v2: Map<String, List<String>>
get() = _id3v2
private val _vorbis = mutableMapOf<String, MutableList<String>>()
/** The vorbis comments found in the file. Can have more than one value. */
val vorbis: Map<String, List<String>>
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())
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.storage package org.oxycblt.auxio.music.filesystem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -41,7 +41,7 @@ class DirectoryAdapter(private val listener: Listener) :
override fun getItemCount() = dirs.size override fun getItemCount() = dirs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
MusicDirViewHolder.new(parent) MusicDirViewHolder.from(parent)
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) = override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener) 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) : 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater)) MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -15,15 +15,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.storage package org.oxycblt.auxio.music.filesystem
import android.content.Context import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.os.storage.StorageVolume import android.os.storage.StorageVolume
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.google.android.exoplayer2.util.MimeTypes
import java.io.File import java.io.File
import org.oxycblt.auxio.R import org.oxycblt.auxio.R

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.storage package org.oxycblt.auxio.music.filesystem
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentResolver import android.content.ContentResolver

View file

@ -15,13 +15,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.storage package org.oxycblt.auxio.music.filesystem
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -30,7 +31,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -42,10 +42,8 @@ import org.oxycblt.auxio.util.showToast
class MusicDirsDialog : class MusicDirsDialog :
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener { ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this) private val dirAdapter = DirectoryAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
private val storageManager: StorageManager by lifecycleObject { binding -> private var storageManager: StorageManager? = null
binding.context.getSystemServiceCompat(StorageManager::class)
}
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicDirsBinding.inflate(inflater) DialogMusicDirsBinding.inflate(inflater)
@ -57,7 +55,10 @@ class MusicDirsDialog :
.setNeutralButton(R.string.lbl_add, null) .setNeutralButton(R.string.lbl_add, null)
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ -> .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())) val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (dirs != newDirs) { if (dirs != newDirs) {
logD("Committing changes") logD("Committing changes")
@ -67,7 +68,11 @@ class MusicDirsDialog :
} }
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
val launcher = val context = requireContext()
val storageManager =
context.getSystemServiceCompat(StorageManager::class).also { storageManager = it }
openDocumentTreeLauncher =
registerForActivityResult( registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs) ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
@ -79,7 +84,10 @@ class MusicDirsDialog :
val dialog = it as AlertDialog val dialog = it as AlertDialog
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher") logD("Opening launcher")
launcher.launch(null) requireNotNull(openDocumentTreeLauncher) {
"Document tree launcher was not available"
}
.launch(null)
} }
} }
@ -88,7 +96,7 @@ class MusicDirsDialog :
itemAnimator = null itemAnimator = null
} }
var dirs = settings.getMusicDirs(storageManager) var dirs = Settings(context).getMusicDirs(storageManager)
if (savedInstanceState != null) { if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
@ -127,6 +135,8 @@ class MusicDirsDialog :
override fun onDestroyBinding(binding: DialogMusicDirsBinding) { override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
storageManager = null
openDocumentTreeLauncher = null
binding.dirsRecycler.adapter = null binding.dirsRecycler.adapter = null
} }
@ -153,7 +163,9 @@ class MusicDirsDialog :
DocumentsContract.buildDocumentUriUsingTree( DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri)) uri, DocumentsContract.getTreeDocumentId(uri))
val treeUri = DocumentsContract.getTreeDocumentId(docUri) 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) { if (dir != null) {
dirAdapter.add(dir) dirAdapter.add(dir)
@ -176,7 +188,7 @@ class MusicDirsDialog :
private fun isUiModeInclude(binding: DialogMusicDirsBinding) = private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include 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_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE" const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
} }

View file

@ -15,61 +15,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
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.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /// --- GENERIC PARSING ---
* 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()
/** /**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within * Parse a multi-value tag based on the user configuration. If the value is already composed of more
* MediaStore's TRACK column, and combine the track and disc value into a single field where the * than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* disc number is the 4th+ digit. * user's separator preferences.
* @return The disc number extracted from the combined integer field, or null if the value was zero. * @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() fun List<String>.parseMultiValue(settings: Settings) =
if (size == 1) {
/** first().maybeParseBySeparators(settings)
* Parse the number out of a combined number + total position [String] field. These fields often } else {
* appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /. // Nothing to do.
* @return The number value extracted from the string field, or null if the value could not be this
* 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)
/** /**
* Split a [String] by the given selector, automatically handling escaped characters that satisfy * 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<String> {
} }
/** /**
* Parse a multi-value tag based on the user configuration. If the value is already composed of more * Fix trailing whitespace or blank contents in a [String].
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the * @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or
* user's separator preferences. * empty.
* @param settings [Settings] required to obtain user separator configuration.
* @return A new list of one or more [String]s.
*/ */
fun List<String>.parseMultiValue(settings: Settings) = fun String.correctWhitespace() = trim().ifBlank { null }
if (size == 1) {
get(0).maybeParseSeparators(settings) /**
} else { * Fix trailing whitespace or blank contents within a list of [String]s.
// Nothing to do. * @return A list of non-blank strings with trailing whitespace removed.
this.map { it.trim() } */
} fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/** /**
* Attempt to parse a string by the user's separator preferences. * Attempt to parse a string by the user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration. * @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. * @return A list of one or more [String]s that were split up by the user-defined separators.
*/ */
fun String.maybeParseSeparators(settings: Settings): List<String> { private fun String.maybeParseBySeparators(settings: Settings): List<String> {
// Get the separators the user desires. If null, there's nothing to do. // Get the separators the user desires. If null, there's nothing to do.
val separators = settings.musicSeparators ?: return listOf(this) 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]. * Parse the number out of a ID3v2-style number + total position [String] field. These fields
* @return A [UUID] converted from the [String] value, or null if the value was not valid. * consist of a number and an (optional) total value delimited by a /.
* @see UUID.fromString * @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? = fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}
/** /**
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * 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<String>.parseId3GenreNames(settings: Settings) = fun List<String>.parseId3GenreNames(settings: Settings) =
if (size == 1) { if (size == 1) {
get(0).parseId3GenreNames(settings) first().parseId3MultiValueGenre(settings)
} else { } else {
// Nothing to split, just map any ID3v1 genres to their name counterparts. // Nothing to split, just map any ID3v1 genres to their name counterparts.
map { it.parseId3v1Genre() ?: it } map { it.parseId3v1Genre() ?: it }
@ -172,8 +134,8 @@ fun List<String>.parseId3GenreNames(settings: Settings) =
* Parse a single ID3v1/ID3v2 integer genre field into their named representations. * Parse a single ID3v1/ID3v2 integer genre field into their named representations.
* @return A list of one or more genre names. * @return A list of one or more genre names.
*/ */
fun String.parseId3GenreNames(settings: Settings) = private fun String.parseId3MultiValueGenre(settings: Settings) =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings) parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
/** /**
* Parse an ID3v1 integer genre field. * Parse an ID3v1 integer genre field.
@ -182,15 +144,17 @@ fun String.parseId3GenreNames(settings: Settings) =
*/ */
private fun String.parseId3v1Genre(): String? { private fun String.parseId3v1Genre(): String? {
// ID3v1 genres are a plain integer value without formatting, so in that case // 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 // try to index the genre table with such.
// to some other hard-coded values. val numeric =
val numeric = toIntOrNull() ?: return when (this) { toIntOrNull()
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number. // Not a numeric value, try some other fixed values.
"CR" -> "Cover" ?: return when (this) {
"RX" -> "Remix" // CR and RX are not technically ID3v1, but are formatted similarly to a plain
else -> null // number.
} "CR" -> "Cover"
"RX" -> "Remix"
else -> null
}
return GENRE_TABLE.getOrNull(numeric) return GENRE_TABLE.getOrNull(numeric)
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = '&'
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.parsing
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -27,7 +27,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment 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 * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() { class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogSeparatorsBinding.inflate(inflater) DialogSeparatorsBinding.inflate(inflater)
@ -45,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
.setTitle(R.string.set_separators) .setTitle(R.string.set_separators)
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ -> .setPositiveButton(R.string.lbl_save) { _, _ ->
settings.musicSeparators = getCurrentSeparators() Settings(requireContext()).musicSeparators = getCurrentSeparators()
} }
} }
@ -61,16 +58,18 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
// More efficient to do one iteration through the separator list and initialize // More efficient to do one iteration through the separator list and initialize
// the corresponding CheckBox for each character instead of doing an iteration // the corresponding CheckBox for each character instead of doing an iteration
// through the separator list for each CheckBox. // through the separator list for each CheckBox.
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: settings.musicSeparators)?.forEach { (savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
when (it) { ?: Settings(requireContext()).musicSeparators)
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true ?.forEach {
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true when (it) {
SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true Separators.COMMA -> binding.separatorComma.isChecked = true
SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true
SEPARATOR_AND -> binding.separatorAnd.isChecked = true Separators.SLASH -> binding.separatorSlash.isChecked = true
else -> error("Unexpected separator in settings data") Separators.PLUS -> binding.separatorPlus.isChecked = true
Separators.AND -> binding.separatorAnd.isChecked = true
else -> error("Unexpected separator in settings data")
}
} }
}
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -85,21 +84,15 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
// of use a mapping that could feasibly drift from the actual layout. // of use a mapping that could feasibly drift from the actual layout.
var separators = "" var separators = ""
val binding = requireBinding() val binding = requireBinding()
if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA if (binding.separatorComma.isChecked) separators += Separators.COMMA
if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON if (binding.separatorSemicolon.isChecked) separators += Separators.SEMICOLON
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH if (binding.separatorSlash.isChecked) separators += Separators.SLASH
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS if (binding.separatorPlus.isChecked) separators += Separators.PLUS
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND if (binding.separatorAnd.isChecked) separators += Separators.AND
return separators return separators
} }
companion object { private companion object {
private val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS" const 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 = '&'
} }
} }

View file

@ -39,7 +39,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = artists.size override fun getItemCount() = artists.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistChoiceViewHolder.new(parent) ArtistChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) = override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
holder.bind(artists[position], listener) 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 * 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) : class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) { DialogRecyclerView.ViewHolder(binding.root) {
@ -68,7 +68,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to. * @param listener A [ClickableListListener] to bind interactions to.
*/ */
fun bind(artist: Artist, listener: ClickableListListener) { fun bind(artist: Artist, listener: ClickableListListener) {
binding.root.setOnClickListener { listener.onClick(artist) } listener.bind(artist, this)
binding.pickerImage.bind(artist) binding.pickerImage.bind(artist)
binding.pickerName.text = artist.resolveName(binding.context) 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -40,8 +41,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
} }
override fun onClick(item: Item) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
super.onClick(item) super.onClick(item, viewHolder)
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
// User made a choice, navigate to it. // User made a choice, navigate to it.
navModel.exploreNavigateTo(item) navModel.exploreNavigateTo(item)

View file

@ -22,6 +22,7 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
@ -67,7 +68,7 @@ abstract class ArtistPickerDialog :
binding.pickerRecycler.adapter = null binding.pickerRecycler.adapter = null
} }
override fun onClick(item: Item) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.picker
import android.os.Bundle import android.os.Bundle
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -41,8 +42,8 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
} }
override fun onClick(item: Item) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
super.onClick(item) super.onClick(item, viewHolder)
// User made a choice, play the given song from that artist. // User made a choice, play the given song from that artist.
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value val song = pickerModel.currentItem.value

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.music.picker
import android.view.View import android.view.View
@ -22,7 +39,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = genres.size override fun getItemCount() = genres.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreChoiceViewHolder.new(parent) GenreChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) = override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
holder.bind(genres[position], listener) 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 * 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) : class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) { DialogRecyclerView.ViewHolder(binding.root) {
@ -51,7 +68,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to. * @param listener A [ClickableListListener] to bind interactions to.
*/ */
fun bind(genre: Genre, listener: ClickableListListener) { fun bind(genre: Genre, listener: ClickableListListener) {
binding.root.setOnClickListener { listener.onClick(genre) } listener.bind(genre, this)
binding.pickerImage.bind(genre) binding.pickerImage.bind(genre)
binding.pickerName.text = genre.resolveName(binding.context) 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.music.picker
import android.os.Bundle import android.os.Bundle
@ -6,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener 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. * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener { class GenrePlaybackPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
private val pickerModel: PickerViewModel by viewModels() private val pickerModel: PickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
// Information about what Song to show choices for is initially within the navigation arguments // Information about what Song to show choices for is initially within the navigation arguments
@ -56,7 +75,7 @@ class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBin
binding.pickerRecycler.adapter = null binding.pickerRecycler.adapter = null
} }
override fun onClick(item: Item) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
// User made a choice, play the given song from that genre. // User made a choice, play the given song from that genre.
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" } check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value val song = pickerModel.currentItem.value

View file

@ -28,12 +28,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* contain the music themselves and then exit if the library changes. * contain the music themselves and then exit if the library changes.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PickerViewModel : ViewModel(), MusicStore.Callback { class PickerViewModel : ViewModel(), MusicStore.Listener {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val _currentItem = MutableStateFlow<Music?>(null) private val _currentItem = MutableStateFlow<Music?>(null)
/** The current item whose artists should be shown in the picker. Null if there is no item. */ /** The current item whose artists should be shown in the picker. Null if there is no item. */
val currentItem: StateFlow<Music?> get() = _currentItem val currentItem: StateFlow<Music?>
get() = _currentItem
private val _artistChoices = MutableStateFlow<List<Artist>>(listOf()) private val _artistChoices = MutableStateFlow<List<Artist>>(listOf())
/** The current [Artist] choices. Empty if no item is shown in the picker. */ /** The current [Artist] choices. Empty if no item is shown in the picker. */
@ -46,7 +47,7 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
get() = _genreChoices get() = _genreChoices
override fun onCleared() { override fun onCleared() {
musicStore.removeCallback(this) musicStore.removeListener(this)
} }
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
@ -75,5 +76,4 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
else -> {} else -> {}
} }
} }
} }

View file

@ -51,10 +51,10 @@ import org.oxycblt.auxio.util.logW
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Indexer private constructor() { class Indexer private constructor() {
private var lastResponse: Response? = null @Volatile private var lastResponse: Result<MusicStore.Library>? = null
private var indexingState: Indexing? = null @Volatile private var indexingState: Indexing? = null
private var controller: Controller? = null @Volatile private var controller: Controller? = null
private var callback: Callback? = null @Volatile private var listener: Listener? = null
/** Whether music loading is occurring or not. */ /** Whether music loading is occurring or not. */
val isIndexing: Boolean 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 * 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 * 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. * @param controller The [Controller] to register. Will do nothing if already registered.
*/ */
@Synchronized @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 * 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 [Callback] at a time. Will invoke all * the current music loading state. There can be only one [Listener] 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 callback The [Callback] to add. * @param listener The [Listener] to add.
*/ */
@Synchronized @Synchronized
fun registerCallback(callback: Callback) { fun registerListener(listener: Listener) {
if (BuildConfig.DEBUG && this.callback != null) { if (BuildConfig.DEBUG && this.listener != null) {
logW("Listener is already registered") logW("Listener is already registered")
return return
} }
@ -120,24 +120,24 @@ class Indexer private constructor() {
// Initialize the listener with the current state. // Initialize the listener with the current state.
val currentState = val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
callback.onIndexerStateChanged(currentState) listener.onIndexerStateChanged(currentState)
this.callback = callback this.listener = listener
} }
/** /**
* Unregister a [Callback] from this instance, preventing it from recieving any further updates. * Unregister a [Listener] from this instance, preventing it from recieving any further updates.
* @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if * @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if
* invoked by another [Callback] implementation. * invoked by another [Listener] implementation.
* @see Callback * @see Listener
*/ */
@Synchronized @Synchronized
fun unregisterCallback(callback: Callback) { fun unregisterListener(listener: Listener) {
if (BuildConfig.DEBUG && this.callback !== callback) { if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller") logW("Given controller did not match current controller")
return 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. * be written, but no cache entries will be loaded into the new library.
*/ */
suspend fun index(context: Context, withCache: Boolean) { suspend fun index(context: Context, withCache: Boolean) {
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) == val result =
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
emitCompletion(Response.NoPerms)
return
}
val response =
try { try {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val library = indexImpl(context, withCache) val library = indexImpl(context, withCache)
if (library != null) { logD(
// Successfully loaded a library. "Music indexing completed successfully in " +
logD( "${System.currentTimeMillis() - start}ms")
"Music indexing completed successfully in " + Result.success(library)
"${System.currentTimeMillis() - start}ms")
Response.Ok(library)
} else {
// Loaded a library, but it contained no music.
logE("No music found")
Response.NoMusic
}
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine. // Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled") logD("Loading routine was cancelled")
@ -178,10 +164,9 @@ class Indexer private constructor() {
// Music loading process failed due to something we have not handled. // Music loading process failed due to something we have not handled.
logE("Music indexing failed") logE("Music indexing failed")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
Response.Err(e) Result.failure(e)
} }
emitCompletion(result)
emitCompletion(response)
} }
/** /**
@ -212,9 +197,17 @@ class Indexer private constructor() {
* @param context [Context] required to load music. * @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still * @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. * 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 // Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music // enables version-specific features in order to create the best possible music
// experience. // experience.
@ -236,12 +229,8 @@ class Indexer private constructor() {
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor) val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val songs = buildSongs(metadataExtractor, Settings(context)) val songs =
if (songs.isEmpty()) { buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
// No songs, nothing else to do.
return null
}
// Build the rest of the music library from the song list. This is much more powerful // 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. // and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis() val buildStart = System.currentTimeMillis()
@ -249,7 +238,6 @@ class Indexer private constructor() {
val artists = buildArtists(songs, albums) val artists = buildArtists(songs, albums)
val genres = buildGenres(songs) val genres = buildGenres(songs)
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return MusicStore.Library(songs, albums, artists, genres) return MusicStore.Library(songs, albums, artists, genres)
} }
@ -388,17 +376,17 @@ class Indexer private constructor() {
val state = val state =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state) 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 * 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 * loading process to external code. Will check if the callee has not been canceled and thus has
* the ability to emit a new state * 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. * process.
*/ */
private suspend fun emitCompletion(response: Response) { private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
yield() yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on // 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. // a background thread. Does not occur in emitIndexing due to efficiency reasons.
@ -406,12 +394,12 @@ class Indexer private constructor() {
synchronized(this) { synchronized(this) {
// Do not check for redundancy here, as we actually need to notify a switch // Do not check for redundancy here, as we actually need to notify a switch
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
lastResponse = response lastResponse = result
indexingState = null indexingState = null
// Signal that the music loading process has been completed. // Signal that the music loading process has been completed.
val state = State.Complete(response) val state = State.Complete(result)
controller?.onIndexerStateChanged(state) controller?.onIndexerStateChanged(state)
callback?.onIndexerStateChanged(state) listener?.onIndexerStateChanged(state)
} }
} }
} }
@ -427,10 +415,9 @@ class Indexer private constructor() {
/** /**
* Music loading has completed. * Music loading has completed.
* @param response The outcome of the music loading process. * @param result The outcome of the music loading process.
* @see Response
*/ */
data class Complete(val response: Response) : State() data class Complete(val result: Result<MusicStore.Library>) : State()
} }
/** /**
@ -451,35 +438,26 @@ class Indexer private constructor() {
class Songs(val current: Int, val total: Int) : Indexing() class Songs(val current: Int, val total: Int) : Indexing()
} }
/** Represents the possible outcomes of the music loading process. */ /** Thrown when the required permissions to load the music library have not been granted yet. */
sealed class Response { class NoPermissionException : Exception() {
/** override val message: String
* Music load was successful and produced a [MusicStore.Library]. get() = "Not granted permissions to load music library"
* @param library The loaded [MusicStore.Library]. }
*/
data class Ok(val library: MusicStore.Library) : Response()
/** /** Thrown when no music was found on the device. */
* Music loading encountered an unexpected error. class NoMusicException : Exception() {
* @param throwable The error thrown. override val message: String
*/ get() = "Unable to find any music"
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()
} }
/** /**
* A listener for rapid-fire changes in the music loading state. * 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. * 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]. * the [MusicStore.Library].
*/ */
interface Callback { interface Listener {
/** /**
* Called when the current state of the Indexer changed. * 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 * 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. * 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 * Called when a new music loading process was requested. Implementations should forward
* this to [index]. * this to [index].
@ -514,8 +492,7 @@ class Indexer private constructor() {
* system to load audio. * system to load audio.
*/ */
val PERMISSION_READ_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 // READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
Manifest.permission.READ_MEDIA_AUDIO Manifest.permission.READ_MEDIA_AUDIO
} else { } else {

View file

@ -72,7 +72,6 @@ class IndexingNotification(private val context: Context) :
// Determinate state, show an active progress meter. Since these updates arrive // Determinate state, show an active progress meter. Since these updates arrive
// highly rapidly, only update every 1.5 seconds to prevent notification rate // highly rapidly, only update every 1.5 seconds to prevent notification rate
// limiting. // limiting.
// TODO: Can I port this to the playback notification somehow?
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) { if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
return false return false

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.system
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.database.ContentObserver import android.database.ContentObserver
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore 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.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -54,7 +55,8 @@ import org.oxycblt.auxio.util.logD
* *
* @author Alexander Capehart (OxygenCobalt) * @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 indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.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 // 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. // condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver() indexerContentObserver = SystemContentObserver()
settings = Settings(this, this) settings = Settings(this)
settings.addListener(this)
indexer.registerController(this) indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early // An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music. // 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 // Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur. // events will not occur.
indexerContentObserver.release() indexerContentObserver.release()
settings.release() settings.removeListener(this)
indexer.unregisterController(this) indexer.unregisterController(this)
// Then cancel any remaining music loading jobs. // Then cancel any remaining music loading jobs.
serviceJob.cancel() serviceJob.cancel()
@ -126,11 +129,11 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onIndexerStateChanged(state: Indexer.State?) { override fun onIndexerStateChanged(state: Indexer.State?) {
when (state) { when (state) {
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
is Indexer.State.Complete -> { is Indexer.State.Complete -> {
if (state.response is Indexer.Response.Ok && val newLibrary = state.result.getOrNull()
state.response.library != musicStore.library) { if (newLibrary != null && newLibrary != musicStore.library) {
logD("Applying new library") logD("Applying new library")
val newLibrary = state.response.library
// We only care if the newly-loaded library is going to replace a previously // We only care if the newly-loaded library is going to replace a previously
// loaded library. // loaded library.
if (musicStore.library != null) { if (musicStore.library != null) {
@ -149,9 +152,6 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// handled right now. // handled right now.
updateIdleSession() updateIdleSession()
} }
is Indexer.State.Indexing -> {
updateActiveSession(state.indexing)
}
null -> { null -> {
// Null is the indeterminate state that occurs on app startup or after // 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 // 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, // 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. // 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 // 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)) { if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post() observingNotification.post()
} }
@ -230,7 +230,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// --- SETTING CALLBACKS --- // --- SETTING CALLBACKS ---
override fun onSettingChanged(key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) { when (key) {
// Hook changes in music settings to a new music loading event. // Hook changes in music settings to a new music loading event.
getString(R.string.set_key_exclude_non_music), getString(R.string.set_key_exclude_non_music),
@ -287,8 +287,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
} }
} }
companion object { private companion object {
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
private const val REINDEX_DELAY_MS = 500L const val REINDEX_DELAY_MS = 500L
} }
} }

View file

@ -23,6 +23,7 @@ import android.media.audiofx.AudioEffect
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -53,13 +54,7 @@ class PlaybackPanelFragment :
StyledSeekBar.Listener { StyledSeekBar.Listener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
// contract analogue for this intent, so the generic contract is used instead.
private val equalizerLauncher by lifecycleObject {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// Nothing to do
}
}
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater) FragmentPlaybackPanelBinding.inflate(inflater)
@ -70,6 +65,13 @@ class PlaybackPanelFragment :
) { ) {
super.onBindingCreated(binding, savedInstanceState) 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 --- // --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets -> binding.root.setOnApplyWindowInsetsListener { view, insets ->
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
@ -100,6 +102,7 @@ class PlaybackPanelFragment :
binding.playbackSeekBar.listener = this binding.playbackSeekBar.listener = this
// Set up actions // Set up actions
// TODO: Add better playback button accessibility
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() } binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() } binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
@ -116,6 +119,7 @@ class PlaybackPanelFragment :
} }
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
equalizerLauncher = null
binding.playbackToolbar.setOnMenuItemClickListener(null) binding.playbackToolbar.setOnMenuItemClickListener(null)
// Marquee elements leak if they are not disabled when the views are destroyed. // Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false binding.playbackSong.isSelected = false
@ -127,10 +131,9 @@ class PlaybackPanelFragment :
when (item.itemId) { when (item.itemId) {
R.id.action_open_equalizer -> { R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible. // Launch the system equalizer app, if possible.
// TODO: Move this to a utility
val equalizerIntent = val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) 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. // in particular.
.putExtra( .putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
@ -138,7 +141,10 @@ class PlaybackPanelFragment :
// music playback. // music playback.
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
try { try {
equalizerLauncher.launch(equalizerIntent) requireNotNull(equalizerLauncher) {
"Equalizer panel launcher was not available"
}
.launch(equalizerIntent)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
requireContext().showToast(R.string.err_no_app) requireContext().showToast(R.string.err_no_app)
} }

View file

@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.context
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackViewModel(application: Application) : class PlaybackViewModel(application: Application) :
AndroidViewModel(application), PlaybackStateManager.Callback { AndroidViewModel(application), PlaybackStateManager.Listener {
private val settings = Settings(application) private val settings = Settings(application)
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private var lastPositionJob: Job? = null private var lastPositionJob: Job? = null
@ -70,8 +70,8 @@ class PlaybackViewModel(application: Application) :
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null) private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
/** /**
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
* playing a [Song] from one of it's [Artist]s. * [Song] from one of it's [Artist]s.
* @see playFromArtist * @see playFromArtist
*/ */
val artistPickerSong: StateFlow<Song?> val artistPickerSong: StateFlow<Song?>
@ -79,8 +79,8 @@ class PlaybackViewModel(application: Application) :
private val _genrePlaybackPickerSong = MutableStateFlow<Song?>(null) private val _genrePlaybackPickerSong = MutableStateFlow<Song?>(null)
/** /**
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
* a [Song] from one of it's [Genre]s. * [Song] from one of it's [Genre]s.
*/ */
val genrePickerSong: StateFlow<Song?> val genrePickerSong: StateFlow<Song?>
get() = _genrePlaybackPickerSong get() = _genrePlaybackPickerSong
@ -93,11 +93,11 @@ class PlaybackViewModel(application: Application) :
get() = playbackManager.currentAudioSessionId get() = playbackManager.currentAudioSessionId
init { init {
playbackManager.addCallback(this) playbackManager.addListener(this)
} }
override fun onCleared() { override fun onCleared() {
playbackManager.removeCallback(this) playbackManager.removeListener(this)
} }
override fun onIndexMoved(index: Int) { override fun onIndexMoved(index: Int) {

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
@ -27,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemQueueSongBinding 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.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer 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. * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueSongViewHolder>() { class QueueAdapter(private val listener: EditableListListener) :
RecyclerView.Adapter<QueueSongViewHolder>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK) private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this // 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 // 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<QueueS
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
QueueSongViewHolder.new(parent) QueueSongViewHolder.from(parent)
override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) = override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) =
throw IllegalStateException() throw IllegalStateException()
@ -121,29 +122,13 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
} }
} }
/** A listener for queue list events. */ private companion object {
interface Listener { val PAYLOAD_UPDATE_POSITION = Any()
/**
* Called when a [RecyclerView.ViewHolder] in the list as clicked.
* @param viewHolder The [RecyclerView.ViewHolder] that was clicked.
*/
fun onClick(viewHolder: RecyclerView.ViewHolder)
/**
* 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_UPDATE_POSITION = Any()
} }
} }
/** /**
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [new] to create an * A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [from] to create an
* instance. * instance.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -190,26 +175,17 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* @param song The new [Song] to bind. * @param song The new [Song] to bind.
* @param listener A [QueueAdapter.Listener] to bind interactions to. * @param listener A [EditableListListener] to bind interactions to.
*/ */
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: QueueAdapter.Listener) { fun bind(song: Song, listener: EditableListListener) {
binding.body.setOnClickListener { listener.onClick(this) } listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(binding.context) binding.songInfo.text = song.resolveArtistContents(binding.context)
// TODO: Why is this here? // Not swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See QueueDragCallback for why this is done.
binding.background.isInvisible = true binding.background.isInvisible = true
// Set up the drag handle to start a drag whenever it is touched.
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
binding.songDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUp(this)
true
} else false
}
} }
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = fun from(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */

View file

@ -24,7 +24,10 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
@ -36,13 +39,11 @@ import org.oxycblt.auxio.util.logD
* A [ViewBindingFragment] that displays an editable queue. * A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.Listener { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
private val queueModel: QueueViewModel by activityViewModels() private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this) private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject { private var touchHelper: ItemTouchHelper? = null
ItemTouchHelper(QueueDragCallback(queueModel))
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
@ -52,7 +53,10 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
// --- UI SETUP --- // --- UI SETUP ---
binding.queueRecycler.apply { binding.queueRecycler.apply {
adapter = queueAdapter 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 // Sometimes the scroll can change without the listener being updated, so we also
@ -77,13 +81,12 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
binding.queueRecycler.adapter = null binding.queueRecycler.adapter = null
} }
override fun onClick(viewHolder: RecyclerView.ViewHolder) { override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
// Clicking on a queue item should start playing it.
queueModel.goto(viewHolder.bindingAdapterPosition) queueModel.goto(viewHolder.bindingAdapterPosition)
} }
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder) requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
} }
private fun updateDivider() { private fun updateDivider() {
@ -108,17 +111,25 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
queueModel.finishReplace() queueModel.finishReplace()
// If requested, scroll to a new item (occurs when the index moves) // If requested, scroll to a new item (occurs when the index moves)
// TODO: Scroll to center/top instead of bottom
val scrollTo = queueModel.scrollTo val scrollTo = queueModel.scrollTo
if (scrollTo != null) { 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 lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition() val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition() val end = lmm.findLastCompletelyVisibleItemPosition()
if (scrollTo !in start..end) { val notInitialized =
logD("Scrolling to new position") 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) 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() queueModel.finishScrollTo()

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueViewModel : ViewModel(), PlaybackStateManager.Callback { class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val _queue = MutableStateFlow(listOf<Song>()) private val _queue = MutableStateFlow(listOf<Song>())
@ -47,7 +47,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
var scrollTo: Int? = null var scrollTo: Int? = null
init { init {
playbackManager.addCallback(this) playbackManager.addListener(this)
} }
/** /**
@ -135,6 +135,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
playbackManager.removeCallback(this) playbackManager.removeListener(this)
} }
} }

View file

@ -26,15 +26,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPreAmpBinding import org.oxycblt.auxio.databinding.DialogPreAmpBinding
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
/** /**
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() { class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
@ -42,9 +39,12 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
.setTitle(R.string.set_pre_amp) .setTitle(R.string.set_pre_amp)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val binding = requireBinding() val binding = requireBinding()
settings.replayGainPreAmp = Settings(requireContext()).replayGainPreAmp =
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
} }
.setNeutralButton(R.string.lbl_reset) { _, _ ->
Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
}
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }
@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
// First initialization, we need to supply the sliders with the values from // 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 // settings. After this, the sliders save their own state, so we do not need to
// do any restore behavior. // do any restore behavior.
val preAmp = settings.replayGainPreAmp val preAmp = Settings(requireContext()).replayGainPreAmp
binding.withTagsSlider.value = preAmp.with binding.withTagsSlider.value = preAmp.with
binding.withoutTagsSlider.value = preAmp.without binding.withoutTagsSlider.value = preAmp.without
} }

View file

@ -18,33 +18,38 @@
package org.oxycblt.auxio.playback.replaygain package org.oxycblt.auxio.playback.replaygain
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.google.android.exoplayer2.C 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.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor import com.google.android.exoplayer2.audio.BaseAudioProcessor
import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.util.MimeTypes
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 java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.pow import kotlin.math.pow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.extractor.Tags
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD 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. * 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 * 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. * 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) * @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 playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context) private val settings = Settings(context)
private var lastFormat: Format? = null
private var volume = 1f private var volume = 1f
set(value) { set(value) {
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
flush() 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 --- // --- REPLAYGAIN PARSING ---
/** /**
* Updates the volume adjustment based on the given [Metadata]. * Updates the volume adjustment based on the given [Format].
* @param metadata The [Metadata] of the currently playing track, or null if the track has no * @param format The [Format] of the currently playing track, or null if nothing is playing.
* [Metadata].
*/ */
fun applyReplayGain(metadata: Metadata?) { private fun applyReplayGain(format: Format?) {
// TODO: Allow this to automatically obtain it's own [Metadata]. lastFormat = format
val gain = metadata?.let(::parseReplayGain) val gain = parseReplayGain(format ?: return)
val preAmp = settings.replayGainPreAmp val preAmp = settings.replayGainPreAmp
val adjust = val adjust =
if (gain != null) { if (gain != null) {
logD("Found ReplayGain adjustment $gain")
// ReplayGain is configurable, so determine what to do based off of the mode. // ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain = val useAlbumGain =
when (settings.replayGainMode) { when (settings.replayGainMode) {
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
} }
/** /**
* Parse ReplayGain information from the given [Metadata]. * Parse ReplayGain information from the given [Format].
* @param metadata The [Metadata] to parse. * @param format The [Format] to parse.
* @return A [Gain] adjustment, or null if there was no adjustments to parse. * @return A [Adjustment] adjustment, or null if there were no valid adjustments.
*/ */
private fun parseReplayGain(metadata: Metadata): Gain? { private fun parseReplayGain(format: Format): Adjustment? {
// TODO: Unify this parser with the music parser? They both grok Metadata. val tags = Tags(format.metadata ?: return null)
var trackGain = 0f var trackGain = 0f
var albumGain = 0f var albumGain = 0f
var found = false
val tags = mutableListOf<GainTag>() // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
// replaygain_*_gain tag.
for (i in 0 until metadata.length()) { if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
val entry = metadata.get(i) tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
?.run { first().parseReplayGainAdjustment() }
val key: String? ?.let { trackGain = it }
val value: String tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
?.run { first().parseReplayGainAdjustment() }
when (entry) { ?.let { albumGain = it }
// ID3v2 text information frame, usually these are formatted in lowercase tags.vorbis[TAG_RG_ALBUM_GAIN]
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that ?.run { first().parseReplayGainAdjustment() }
// capitalization is consistent before continuing. ?.let { trackGain = it }
is TextInformationFrame -> { tags.vorbis[TAG_RG_TRACK_GAIN]
key = entry.description ?.run { first().parseReplayGainAdjustment() }
value = entry.values[0] ?.let { albumGain = it }
} } else {
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2 // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
// frame by ExoPlayer (presumably to reduce duplication). // adjustment by 256 to get the gain. This is used alongside the base adjustment
is InternalFrame -> { // intrinsic to the format to create the normalized adjustment. That base adjustment
key = entry.description // is already handled by the media framework, so we just need to apply the more
value = entry.text // specific adjustments.
} tags.vorbis[TAG_R128_TRACK_GAIN]
// Vorbis comment. These are nearly always uppercase, so a check for such is ?.run { first().parseReplayGainAdjustment() }
// skipped. ?.let { trackGain = it / 256f }
is VorbisComment -> { tags.vorbis[TAG_R128_ALBUM_GAIN]
key = entry.key ?.run { first().parseReplayGainAdjustment() }
value = entry.value ?.let { albumGain = it / 256f }
}
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))
}
} }
// Case 1: Normal ReplayGain, most commonly found on MPEG files. return if (trackGain != 0f || albumGain != 0f) {
tags Adjustment(trackGain, albumGain)
.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)
} else { } else {
null 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 --- // --- AUDIO PROCESSOR IMPLEMENTATION ---
override fun onConfigure( 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 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. * @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)
/** private companion object {
* A raw ReplayGain adjustment. const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
* @param key The tag's key. const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
* @param value The tag's adjustment, in dB. const val TAG_R128_TRACK_GAIN = "r128_track_gain"
*/ const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
private data class GainTag(val key: String, val value: Float)
// TODO: Try to phase this out
companion object { /**
private const val TAG_RG_TRACK = "replaygain_track_gain" * Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
private const val TAG_RG_ALBUM = "replaygain_album_gain" * https://github.com/vanilla-music/vanilla
private const val R128_TRACK = "r128_track_gain" */
private const val R128_ALBUM = "r128_album_gain" val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
private val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
} }
} }

View file

@ -85,6 +85,9 @@ interface InternalPlayer {
data class Open(val uri: Uri) : Action() data class Open(val uri: Uri) : Action()
} }
/**
* A representation of the current state of audio playback. Use [from] to create an instance.
*/
class State class State
private constructor( private constructor(
/** Whether the player is actively playing audio or set to play audio in the future. */ /** 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 isAdvancing Whether the player is actively playing audio in this moment.
* @param positionMs The current position of the player. * @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( State(
isPlaying, isPlaying,
// Minor sanity check: Make sure that advancing can't occur if already paused. // Minor sanity check: Make sure that advancing can't occur if already paused.

View file

@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song 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.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE 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 * - If you want to use the playback state with the ExoPlayer instance or system-side things, use
* [org.oxycblt.auxio.playback.system.PlaybackService]. * [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]. * itself should instead use [InternalPlayer].
* *
* All access should be done with [PlaybackStateManager.getInstance]. * All access should be done with [PlaybackStateManager.getInstance].
@ -54,35 +54,40 @@ import org.oxycblt.auxio.util.logW
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val callbacks = mutableListOf<Callback>() private val listeners = mutableListOf<Listener>()
private var internalPlayer: InternalPlayer? = null @Volatile private var internalPlayer: InternalPlayer? = null
private var pendingAction: InternalPlayer.Action? = null @Volatile private var pendingAction: InternalPlayer.Action? = null
private var isInitialized = false @Volatile private var isInitialized = false
/** The currently playing [Song]. Null if nothing is playing. */ /** The currently playing [Song]. Null if nothing is playing. */
val song val song
get() = queue.getOrNull(index) get() = queue.getOrNull(index)
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile
var parent: MusicParent? = null var parent: MusicParent? = null
private set private set
private var _queue = mutableListOf<Song>() @Volatile private var _queue = mutableListOf<Song>()
/** The current queue. */ /** The current queue. */
val queue val queue
get() = _queue get() = _queue
/** The position of the currently playing item in the queue. */ /** The position of the currently playing item in the queue. */
@Volatile
var index = -1 var index = -1
private set private set
/** The current [InternalPlayer] state. */ /** 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 private set
/** The current [RepeatMode] */ /** The current [RepeatMode] */
@Volatile
var repeatMode = RepeatMode.NONE var repeatMode = RepeatMode.NONE
set(value) { set(value) {
field = value field = value
notifyRepeatModeChanged() notifyRepeatModeChanged()
} }
/** Whether the queue is shuffled. */ /** Whether the queue is shuffled. */
@Volatile
var isShuffled = false var isShuffled = false
private set private set
/** /**
@ -93,32 +98,32 @@ class PlaybackStateManager private constructor() {
get() = internalPlayer?.audioSessionId get() = internalPlayer?.audioSessionId
/** /**
* Add a [Callback] to this instance. This can be used to receive changes in the playback state. * Add a [Listener] 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. * Will immediately invoke [Listener] methods to initialize the instance with the current state.
* @param callback The [Callback] to add. * @param listener The [Listener] to add.
* @see Callback * @see Listener
*/ */
@Synchronized @Synchronized
fun addCallback(callback: Callback) { fun addListener(listener: Listener) {
if (isInitialized) { if (isInitialized) {
callback.onNewPlayback(index, queue, parent) listener.onNewPlayback(index, queue, parent)
callback.onRepeatChanged(repeatMode) listener.onRepeatChanged(repeatMode)
callback.onShuffledChanged(isShuffled) listener.onShuffledChanged(isShuffled)
callback.onStateChanged(playerState) listener.onStateChanged(playerState)
} }
callbacks.add(callback) listeners.add(listener)
} }
/** /**
* Remove a [Callback] from this instance, preventing it from recieving any further updates. * Remove a [Listener] 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 * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place. * the first place.
* @see Callback * @see Listener
*/ */
@Synchronized @Synchronized
fun removeCallback(callback: Callback) { fun removeListener(listener: Listener) {
callbacks.remove(callback) listeners.remove(listener)
} }
/** /**
@ -521,10 +526,9 @@ class PlaybackStateManager private constructor() {
* @param database The [PlaybackStateDatabase] to clear te state from * @param database The [PlaybackStateDatabase] to clear te state from
* @return If the state was cleared, false otherwise. * @return If the state was cleared, false otherwise.
*/ */
suspend fun wipeState(database: PlaybackStateDatabase): Boolean { suspend fun wipeState(database: PlaybackStateDatabase) =
logD("Wiping state") try {
logD("Wiping state")
return try {
withContext(Dispatchers.IO) { database.write(null) } withContext(Dispatchers.IO) { database.write(null) }
true true
} catch (e: Exception) { } catch (e: Exception) {
@ -532,7 +536,6 @@ class PlaybackStateManager private constructor() {
logE(e.stackTraceToString()) logE(e.stackTraceToString())
false false
} }
}
/** /**
* Update the playback state to align with a new [MusicStore.Library]. * Update the playback state to align with a new [MusicStore.Library].
@ -586,52 +589,52 @@ class PlaybackStateManager private constructor() {
// --- CALLBACKS --- // --- CALLBACKS ---
private fun notifyIndexMoved() { private fun notifyIndexMoved() {
for (callback in callbacks) { for (callback in listeners) {
callback.onIndexMoved(index) callback.onIndexMoved(index)
} }
} }
private fun notifyQueueChanged() { private fun notifyQueueChanged() {
for (callback in callbacks) { for (callback in listeners) {
callback.onQueueChanged(queue) callback.onQueueChanged(queue)
} }
} }
private fun notifyQueueReworked() { private fun notifyQueueReworked() {
for (callback in callbacks) { for (callback in listeners) {
callback.onQueueReworked(index, queue) callback.onQueueReworked(index, queue)
} }
} }
private fun notifyNewPlayback() { private fun notifyNewPlayback() {
for (callback in callbacks) { for (callback in listeners) {
callback.onNewPlayback(index, queue, parent) callback.onNewPlayback(index, queue, parent)
} }
} }
private fun notifyStateChanged() { private fun notifyStateChanged() {
for (callback in callbacks) { for (callback in listeners) {
callback.onStateChanged(playerState) callback.onStateChanged(playerState)
} }
} }
private fun notifyRepeatModeChanged() { private fun notifyRepeatModeChanged() {
for (callback in callbacks) { for (callback in listeners) {
callback.onRepeatChanged(repeatMode) callback.onRepeatChanged(repeatMode)
} }
} }
private fun notifyShuffledChanged() { private fun notifyShuffledChanged() {
for (callback in callbacks) { for (callback in listeners) {
callback.onShuffledChanged(isShuffled) callback.onShuffledChanged(isShuffled)
} }
} }
/** /**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to * 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 * Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed. * [Song], but no other queue attribute has changed.

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.system
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Bundle 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 * A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent]. * [NotificationComponent].
* @param context [Context] required to initialize components. * @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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MediaSessionComponent(private val context: Context, private val callback: Callback) : class MediaSessionComponent(private val context: Context, private val listener: Listener) :
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback { MediaSessionCompat.Callback(),
PlaybackStateManager.Listener,
SharedPreferences.OnSharedPreferenceChangeListener {
private val mediaSession = private val mediaSession =
MediaSessionCompat(context, context.packageName).apply { MediaSessionCompat(context, context.packageName).apply {
isActive = true isActive = true
@ -55,13 +58,13 @@ class MediaSessionComponent(private val context: Context, private val callback:
} }
private val playbackManager = PlaybackStateManager.getInstance() 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 notification = NotificationComponent(context, mediaSession.sessionToken)
private val provider = BitmapProvider(context) private val provider = BitmapProvider(context)
init { init {
playbackManager.addCallback(this) playbackManager.addListener(this)
mediaSession.setCallback(this) mediaSession.setCallback(this)
} }
@ -79,15 +82,15 @@ class MediaSessionComponent(private val context: Context, private val callback:
*/ */
fun release() { fun release() {
provider.release() provider.release()
settings.release() settings.removeListener(this)
playbackManager.removeCallback(this) playbackManager.removeListener(this)
mediaSession.apply { mediaSession.apply {
isActive = false isActive = false
release() release()
} }
} }
// --- PLAYBACKSTATEMANAGER CALLBACKS --- // --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) { override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.song, playbackManager.parent) updateMediaMetadata(playbackManager.song, playbackManager.parent)
@ -113,7 +116,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSessionState() invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying) notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) { if (!provider.isBusy) {
callback.onPostNotification(notification) listener.onPostNotification(notification)
} }
} }
@ -139,9 +142,9 @@ class MediaSessionComponent(private val context: Context, private val callback:
invalidateSecondaryAction() invalidateSecondaryAction()
} }
// --- SETTINGSMANAGER CALLBACKS --- // --- SETTINGS OVERRIDES ---
override fun onSettingChanged(key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) { when (key) {
context.getString(R.string.set_key_cover_mode) -> context.getString(R.string.set_key_cover_mode) ->
updateMediaMetadata(playbackManager.song, playbackManager.parent) 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?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
super.onPlayFromMediaId(mediaId, extras) super.onPlayFromMediaId(mediaId, extras)
@ -306,7 +309,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
val metadata = builder.build() val metadata = builder.build()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
notification.updateMetadata(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) { if (!provider.isBusy) {
callback.onPostNotification(notification) listener.onPostNotification(notification)
} }
} }
/** An interface for handling changes in the notification configuration. */ /** An interface for handling changes in the notification configuration. */
interface Callback { interface Listener {
/** /**
* Called when the [NotificationComponent] changes, requiring it to be re-posed. * Called when the [NotificationComponent] changes, requiring it to be re-posed.
* @param notification The new [NotificationComponent]. * @param notification The new [NotificationComponent].

View file

@ -148,9 +148,9 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
iconRes, actionName, context.newBroadcastPendingIntent(actionName)) iconRes, actionName, context.newBroadcastPendingIntent(actionName))
.build() .build()
companion object { private companion object {
/** Notification channel used by solely the playback notification. */ /** Notification channel used by solely the playback notification. */
private val CHANNEL_INFO = val CHANNEL_INFO =
ChannelInfo( ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
nameRes = R.string.lbl_playback) nameRes = R.string.lbl_playback)

View file

@ -31,7 +31,6 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.RenderersFactory 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.AudioAttributes
import com.google.android.exoplayer2.audio.AudioCapabilities import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
@ -79,9 +77,8 @@ class PlaybackService :
Service(), Service(),
Player.Listener, Player.Listener,
InternalPlayer, InternalPlayer,
MediaSessionComponent.Callback, MediaSessionComponent.Listener,
Settings.Callback, MusicStore.Listener {
MusicStore.Callback {
// Player components // Player components
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var replayGainProcessor: ReplayGainAudioProcessor private lateinit var replayGainProcessor: ReplayGainAudioProcessor
@ -143,13 +140,14 @@ class PlaybackService :
true) true)
.build() .build()
.also { it.addListener(this) } .also { it.addListener(this) }
replayGainProcessor.addToListeners(player)
// Initialize the core service components // Initialize the core service components
settings = Settings(this, this) settings = Settings(this)
foregroundManager = ForegroundManager(this) foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race // 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. // condition to cause us to load music before we were fully initialize.
playbackManager.registerInternalPlayer(this) playbackManager.registerInternalPlayer(this)
musicStore.addCallback(this) musicStore.addListener(this)
widgetComponent = WidgetComponent(this) widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, this) mediaSessionComponent = MediaSessionComponent(this, this)
registerReceiver( registerReceiver(
@ -185,12 +183,11 @@ class PlaybackService :
super.onDestroy() super.onDestroy()
foregroundManager.release() foregroundManager.release()
settings.release()
// Pause just in case this destruction was unexpected. // Pause just in case this destruction was unexpected.
playbackManager.setPlaying(false) playbackManager.setPlaying(false)
playbackManager.unregisterInternalPlayer(this) playbackManager.unregisterInternalPlayer(this)
musicStore.removeCallback(this) musicStore.removeListener(this)
unregisterReceiver(systemReceiver) unregisterReceiver(systemReceiver)
serviceJob.cancel() serviceJob.cancel()
@ -198,6 +195,7 @@ class PlaybackService :
widgetComponent.release() widgetComponent.release()
mediaSessionComponent.release() mediaSessionComponent.release()
replayGainProcessor.releaseFromListeners(player)
player.release() player.release()
if (openAudioEffectSession) { if (openAudioEffectSession) {
// Make sure to close the audio session when we release the player. // 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 get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun getState(durationMs: Long) = override fun getState(durationMs: Long) =
InternalPlayer.State.new( InternalPlayer.State.from(
player.playWhenReady, player.playWhenReady,
player.isPlaying, player.isPlaying,
// The position value can be below zero or past the expected duration, make // The position value can be below zero or past the expected duration, make
@ -302,24 +300,6 @@ class PlaybackService :
playbackManager.next() 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 --- // --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) { 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 --- // --- OTHER FUNCTIONS ---
private fun broadcastAudioEffectAction(event: String) { private fun broadcastAudioEffectAction(event: String) {

View file

@ -51,11 +51,11 @@ class SearchAdapter(private val listener: SelectableListListener) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent) SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.new(parent) AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent)
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.new(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent) HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
@ -81,9 +81,9 @@ class SearchAdapter(private val listener: SelectableListListener) :
differ.submitList(newList, callback) differ.submitList(newList, callback)
} }
companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) = override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when { when {

View file

@ -53,10 +53,8 @@ import org.oxycblt.auxio.util.*
class SearchFragment : ListFragment<FragmentSearchBinding>() { class SearchFragment : ListFragment<FragmentSearchBinding>() {
private val searchModel: SearchViewModel by androidViewModels() private val searchModel: SearchViewModel by androidViewModels()
private val searchAdapter = SearchAdapter(this) private val searchAdapter = SearchAdapter(this)
private var imm: InputMethodManager? = null
private var launchedKeyboard = false private var launchedKeyboard = false
private val imm: InputMethodManager by lifecycleObject { binding ->
binding.context.getSystemServiceCompat(InputMethodManager::class)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -74,13 +72,15 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
binding.searchToolbar.apply { binding.searchToolbar.apply {
// Initialize the current filtering mode. // Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true menu.findItem(searchModel.getFilterOptionId()).isChecked = true
setNavigationOnClickListener { setNavigationOnClickListener {
// Keyboard is no longer needed. // Keyboard is no longer needed.
imm.hide() hideKeyboard()
findNavController().navigateUp() findNavController().navigateUp()
} }
@ -95,7 +95,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
if (!launchedKeyboard) { if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown // Auto-open the keyboard when this view is shown
imm.show(this) showKeyboard(this)
launchedKeyboard = true launchedKeyboard = true
} }
} }
@ -184,7 +184,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
else -> return else -> return
} }
// Keyboard is no longer needed. // Keyboard is no longer needed.
imm.hide() hideKeyboard()
findNavController().navigate(action) findNavController().navigate(action)
} }
@ -193,7 +193,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) { selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard. // Make selection of obscured items easier by hiding the keyboard.
imm.hide() hideKeyboard()
} }
} }
@ -201,15 +201,19 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
* Safely focus the keyboard on a particular [View]. * Safely focus the keyboard on a particular [View].
* @param view The [View] to focus the keyboard on. * @param view The [View] to focus the keyboard on.
*/ */
private fun InputMethodManager.show(view: View) { private fun showKeyboard(view: View) {
view.apply { view.apply {
requestFocus() 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. */ /** Safely hide the keyboard from this view. */
private fun InputMethodManager.hide() { private fun hideKeyboard() {
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS) requireNotNull(imm) { "InputMethodManager was not available" }
.hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
} }
} }

View file

@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SearchViewModel(application: Application) : class SearchViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback { AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settings = Settings(context) private val settings = Settings(context)
private var lastQuery: String? = null private var lastQuery: String? = null
@ -55,12 +55,12 @@ class SearchViewModel(application: Application) :
get() = _searchResults get() = _searchResults
init { init {
musicStore.addCallback(this) musicStore.addListener(this)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeCallback(this) musicStore.removeListener(this)
} }
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
@ -212,11 +212,11 @@ class SearchViewModel(application: Application) :
search(lastQuery) search(lastQuery)
} }
companion object { private companion object {
/** /**
* Converts the output of [Normalizer] to remove any junk characters added by it's * Converts the output of [Normalizer] to remove any junk characters added by it's
* replacements. * replacements.
*/ */
private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+") val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
} }
} }

View file

@ -152,14 +152,14 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
startActivity(chooserIntent) startActivity(chooserIntent)
} }
companion object { private companion object {
/** The URL to the source code. */ /** 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. */ /** 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. */ /** 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. */ /** The URL to the app author. */
private const val LINK_AUTHOR = "https://github.com/OxygenCobalt" const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
} }
} }

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build import android.os.Build
import android.os.storage.StorageManager import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate 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.image.CoverMode
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories import org.oxycblt.auxio.music.filesystem.MusicDirectories
import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
@ -40,20 +41,14 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Object * A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
* mutability * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Settings(private val context: Context, private val callback: Callback? = null) : class Settings(private val context: Context) {
SharedPreferences.OnSharedPreferenceChangeListener {
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) 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 * Migrate any settings from an old version into their modern counterparts. This can cause data
* loss depending on the feasibility of a migration. * 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 * Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
* originally attached. * @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
*/ */
fun release() { fun addListener(listener: OnSharedPreferenceChangeListener) {
inner.unregisterOnSharedPreferenceChangeListener(this) inner.registerOnSharedPreferenceChangeListener(listener)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
unlikelyToBeNull(callback).onSettingChanged(key)
} }
/** /**
* Simplified callback for settings changes. * Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
* settings updates from being sent to ti.t
*/ */
interface Callback { fun removeListener(listener: OnSharedPreferenceChangeListener) {
// TODO: Refactor this lifecycle inner.unregisterOnSharedPreferenceChangeListener(listener)
/**
* Called when a setting has changed.
* @param key The key of the setting that changed.
*/
fun onSettingChanged(key: String)
} }
// --- VALUES --- // --- VALUES ---

View file

@ -162,8 +162,8 @@ constructor(
} }
} }
companion object { private companion object {
private val PREFERENCE_DEFAULT_VALUE_FIELD: Field by val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
lazyReflectedField(Preference::class, "mDefaultValue") lazyReflectedField(Preference::class, "mDefaultValue")
} }
} }

View file

@ -24,7 +24,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() { class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
@ -62,11 +62,10 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
* @param preference The [IntListPreference] to display. * @param preference The [IntListPreference] to display.
* @return A new instance. * @return A new instance.
*/ */
fun new(preference: IntListPreference): IntListPreferenceDialog { fun from(preference: IntListPreference) =
return IntListPreferenceDialog().apply { IntListPreferenceDialog().apply {
// Populate the key field required by PreferenceDialogFragmentCompat. // Populate the key field required by PreferenceDialogFragmentCompat.
arguments = Bundle().apply { putString(ARG_KEY, preference.key) } arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
} }
}
} }
} }

View file

@ -76,7 +76,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
is IntListPreference -> { is IntListPreference -> {
// Copy the built-in preference dialog launching code into our project so // Copy the built-in preference dialog launching code into our project so
// we can automatically use the provided preference class. // we can automatically use the provided preference class.
val dialog = IntListPreferenceDialog.new(preference) val dialog = IntListPreferenceDialog.from(preference)
dialog.setTargetFragment(this, 0) dialog.setTargetFragment(this, 0)
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
} }
@ -104,46 +104,44 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
val context = requireContext()
// Hook generic preferences to their specified preferences // Hook generic preferences to their specified preferences
// TODO: These seem like good things to put into a side navigation view, if I choose to // TODO: These seem like good things to put into a side navigation view, if I choose to
// do one. // do one.
when (preference.key) { when (preference.key) {
context.getString(R.string.set_key_save_state) -> { getString(R.string.set_key_save_state) -> {
playbackModel.savePlaybackState { saved -> playbackModel.savePlaybackState { saved ->
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
if (saved) { if (saved) {
this.context?.showToast(R.string.lbl_state_saved) context?.showToast(R.string.lbl_state_saved)
} else { } 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 -> playbackModel.wipePlaybackState { wiped ->
if (wiped) { if (wiped) {
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_wiped) context?.showToast(R.string.lbl_state_wiped)
} else { } 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 -> playbackModel.tryRestorePlaybackState { restored ->
if (restored) { if (restored) {
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_restored) context?.showToast(R.string.lbl_state_restored)
} else { } 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() getString(R.string.set_key_reindex) -> musicModel.refresh()
context.getString(R.string.set_key_rescan) -> musicModel.rescan() getString(R.string.set_key_rescan) -> musicModel.rescan()
else -> return super.onPreferenceTreeClick(preference) else -> return super.onPreferenceTreeClick(preference)
} }
@ -151,8 +149,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
private fun setupPreference(preference: Preference) { private fun setupPreference(preference: Preference) {
val context = requireActivity() val settings = Settings(requireContext())
val settings = Settings(context)
if (!preference.isVisible) { if (!preference.isVisible) {
// Nothing to do. // Nothing to do.
@ -165,30 +162,31 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
when (preference.key) { when (preference.key) {
context.getString(R.string.set_key_theme) -> { getString(R.string.set_key_theme) -> {
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value -> Preference.OnPreferenceChangeListener { _, value ->
AppCompatDelegate.setDefaultNightMode(value as Int) AppCompatDelegate.setDefaultNightMode(value as Int)
true true
} }
} }
context.getString(R.string.set_key_accent) -> { getString(R.string.set_key_accent) -> {
preference.summary = context.getString(settings.accent.name) 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 =
Preference.OnPreferenceChangeListener { _, _ -> Preference.OnPreferenceChangeListener { _, _ ->
if (context.isNight) { val activity = requireActivity()
context.recreate() if (activity.isNight) {
activity.recreate()
} }
true true
} }
} }
context.getString(R.string.set_key_cover_mode) -> { getString(R.string.set_key_cover_mode) -> {
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ -> Preference.OnPreferenceChangeListener { _, _ ->
Coil.imageLoader(context).memoryCache?.clear() Coil.imageLoader(requireContext()).memoryCache?.clear()
true true
} }
} }

View file

@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/ */
open class AuxioAppBarLayout open class CoordinatorAppBarLayout
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppBarLayout(context, attrs, defStyleAttr) { 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 * Expand this [AppBarLayout] with respect to the current [RecyclerView] at
* jumping around. * [liftOnScrollTargetViewId], preventing it from jumping around.
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
*/ */
fun expandWithRecycler(recycler: RecyclerView?) { fun expandWithScrollingRecycler() {
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the RecyclerView arg?
setExpanded(true) setExpanded(true)
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) } (findScrollingChild() as? RecyclerView)?.let {
addOnOffsetChangedListener(ExpansionHackListener(it))
}
} }
override fun onDetachedFromWindow() { 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 */ /** @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
} }
} }

View file

@ -92,8 +92,8 @@ class NavigationViewModel : ViewModel() {
/** /**
* Navigate to one of the parent [Artist]'s of the given [Song]. * 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, * @param song The [Song] to navigate with. If there are multiple parent [Artist]s, a picker
* a picker dialog will be shown. * dialog will be shown.
*/ */
fun exploreNavigateToParentArtist(song: Song) { fun exploreNavigateToParentArtist(song: Song) {
exploreNavigateToParentArtistImpl(song, song.artists) exploreNavigateToParentArtistImpl(song, song.artists)
@ -101,8 +101,8 @@ class NavigationViewModel : ViewModel() {
/** /**
* Navigate to one of the parent [Artist]'s of the given [Album]. * 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, * @param album The [Album] to navigate with. If there are multiple parent [Artist]s, a picker
* a picker dialog will be shown. * dialog will be shown.
*/ */
fun exploreNavigateToParentArtist(album: Album) { fun exploreNavigateToParentArtist(album: Album) {
exploreNavigateToParentArtistImpl(album, album.artists) exploreNavigateToParentArtistImpl(album, album.artists)

View file

@ -23,11 +23,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -37,7 +34,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/ */
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() { abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
private var _binding: VB? = null private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/** /**
* Configure the [AlertDialog.Builder] during [onCreateDialog]. * Configure the [AlertDialog.Builder] during [onCreateDialog].
@ -85,25 +81,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
} }
} }
/**
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
* @param create Block to create the object from the [ViewBinding].
*/
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
lifecycleObjects.add(LifecycleObject(null, create))
return object : ReadOnlyProperty<Fragment, T> {
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( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -119,9 +96,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) }
// Configure binding // Configure binding
onBindingCreated(requireBinding(), savedInstanceState) onBindingCreated(requireBinding(), savedInstanceState)
// Apply the newly-configured view to the dialog. // Apply the newly-configured view to the dialog.
@ -132,21 +106,8 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
final override fun onDestroyView() { final override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding)) onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() }
// Clear binding // Clear binding
_binding = null _binding = null
logD("Fragment destroyed") logD("Fragment destroyed")
} }
/** Internal implementation of [lifecycleObject]. */
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) {
data = create(binding)
}
fun clear() {
data = null
}
}
} }

View file

@ -23,8 +23,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -34,7 +32,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/ */
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() { abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/** /**
* Inflate the [ViewBinding] during [onCreateView]. * Inflate the [ViewBinding] during [onCreateView].
@ -75,26 +72,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
} }
} }
/**
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
* @param create Block to create the object from the [ViewBinding].
*/
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
// TODO: Phase this out.
lifecycleObjects.add(LifecycleObject(null, create))
return object : ReadOnlyProperty<Fragment, T> {
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( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -103,9 +80,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) }
// Configure binding // Configure binding
onBindingCreated(requireBinding(), savedInstanceState) onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created") logD("Fragment created")
@ -114,21 +88,8 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
final override fun onDestroyView() { final override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding)) onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() }
// Clear binding // Clear binding
_binding = null _binding = null
logD("Fragment destroyed") logD("Fragment destroyed")
} }
/** Internal implementation of [lifecycleObject]. */
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) {
data = create(binding)
}
fun clear() {
data = null
}
}
} }

View file

@ -41,7 +41,8 @@ class AccentAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = Accent.MAX 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) = override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
throw NotImplementedError() throw NotImplementedError()
@ -75,13 +76,13 @@ class AccentAdapter(private val listener: ClickableListListener) :
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED) notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
} }
companion object { private companion object {
private val PAYLOAD_SELECTION_CHANGED = Any() 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) : 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. * @param listener A [ClickableListListener] to bind interactions to.
*/ */
fun bind(accent: Accent, listener: ClickableListListener) { fun bind(accent: Accent, listener: ClickableListListener) {
listener.bind(accent, this, binding.accent)
binding.accent.apply { 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 // Add a Tooltip based on the content description so that the purpose of this
// button can be clear. // button can be clear.
contentDescription = context.getString(accent.name) contentDescription = context.getString(accent.name)
TooltipCompat.setTooltipText(this, contentDescription) 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. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
fun new(parent: View) = AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater)) fun from(parent: View) =
AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.ui.accent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogAccentBinding 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.list.Item
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -38,7 +38,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class AccentCustomizeDialog : class AccentCustomizeDialog :
ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener { ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener {
private var accentAdapter = AccentAdapter(this) private var accentAdapter = AccentAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
@ -46,6 +45,7 @@ class AccentCustomizeDialog :
builder builder
.setTitle(R.string.set_accent) .setTitle(R.string.set_accent)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val settings = Settings(requireContext())
if (accentAdapter.selectedAccent == settings.accent) { if (accentAdapter.selectedAccent == settings.accent) {
// Nothing to do. // Nothing to do.
return@setPositiveButton return@setPositiveButton
@ -66,7 +66,7 @@ class AccentCustomizeDialog :
if (savedInstanceState != null) { if (savedInstanceState != null) {
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT)) Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
} else { } else {
settings.accent Settings(requireContext()).accent
}) })
} }
@ -80,12 +80,12 @@ class AccentCustomizeDialog :
binding.accentRecycler.adapter = null 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}" } check(item is Accent) { "Unexpected datatype: ${item::class.java}" }
accentAdapter.setSelectedAccent(item) accentAdapter.setSelectedAccent(item)
} }
companion object { private companion object {
private const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT" const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
} }
} }

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.widgets package org.oxycblt.auxio.widgets
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import coil.request.ImageRequest import coil.request.ImageRequest
@ -41,14 +42,15 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class WidgetComponent(private val context: Context) : class WidgetComponent(private val context: Context) :
PlaybackStateManager.Callback, Settings.Callback { PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context, this) private val settings = Settings(context)
private val widgetProvider = WidgetProvider() private val widgetProvider = WidgetProvider()
private val provider = BitmapProvider(context) private val provider = BitmapProvider(context)
init { init {
playbackManager.addCallback(this) playbackManager.addListener(this)
settings.addListener(this)
} }
/** Update [WidgetProvider] with the current playback state. */ /** 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. */ /** Release this instance, preventing any further events from updating the widget instances. */
fun release() { fun release() {
provider.release() provider.release()
settings.release() settings.removeListener(this)
widgetProvider.reset(context) widgetProvider.reset(context)
playbackManager.removeCallback(this) playbackManager.removeListener(this)
} }
// --- CALLBACKS --- // --- CALLBACKS ---
@ -118,7 +120,8 @@ class WidgetComponent(private val context: Context) :
override fun onStateChanged(state: InternalPlayer.State) = update() override fun onStateChanged(state: InternalPlayer.State) = update()
override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = 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) || if (key == context.getString(R.string.set_key_cover_mode) ||
key == context.getString(R.string.set_key_round_mode)) { key == context.getString(R.string.set_key_round_mode)) {
update() update()

View file

@ -80,8 +80,8 @@ fun RemoteViews.setLayoutDirection(@IdRes viewId: Int, layoutDirection: Int) {
} }
/** /**
* Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with * Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with an
* an adaptive layout, in a version-compatible manner. * adaptive layout, in a version-compatible manner.
* @param context [Context] required to backport adaptive layout behavior. * @param context [Context] required to backport adaptive layout behavior.
* @param component [ComponentName] of the app widget layout to update. * @param component [ComponentName] of the app widget layout to update.
* @param views Mapping between different size classes and [RemoteViews] instances. * @param views Mapping between different size classes and [RemoteViews] instances.

View file

@ -9,7 +9,7 @@
android:transitionGroup="true" android:transitionGroup="true"
tools:context=".settings.AboutFragment"> tools:context=".settings.AboutFragment">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/about_appbar" android:id="@+id/about_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true"> app:liftOnScroll="true">
@ -21,7 +21,7 @@
app:navigationIcon="@drawable/ic_back_24" app:navigationIcon="@drawable/ic_back_24"
app:title="@string/lbl_about" /> app:title="@string/lbl_about" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/about_contents" android:id="@+id/about_contents"

View file

@ -8,7 +8,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/home_appbar" android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
@ -37,7 +37,7 @@
app:tabGravity="start" app:tabGravity="start"
app:tabMode="scrollable" /> app:tabMode="scrollable" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<FrameLayout <FrameLayout

View file

@ -7,7 +7,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true" app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/search_recycler"> app:liftOnScrollTargetViewId="@id/search_recycler">
@ -51,7 +51,7 @@
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay> </org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<org.oxycblt.auxio.list.recycler.AuxioRecyclerView <org.oxycblt.auxio.list.recycler.AuxioRecyclerView
android:id="@+id/search_recycler" android:id="@+id/search_recycler"

View file

@ -8,7 +8,7 @@
android:orientation="vertical" android:orientation="vertical"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/settings_appbar" android:id="@+id/settings_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
android:clickable="true" android:clickable="true"
@ -22,7 +22,7 @@
app:navigationIcon="@drawable/ic_back_24" app:navigationIcon="@drawable/ic_back_24"
app:title="@string/set_title" /> app:title="@string/set_title" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/settings_list_fragment" android:id="@+id/settings_list_fragment"

View file

@ -25,6 +25,7 @@
style="@style/Widget.Auxio.Button.Icon.Small" style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="@dimen/spacing_mid_large" android:layout_marginEnd="@dimen/spacing_mid_large"
android:contentDescription="@string/desc_music_dir_delete" android:contentDescription="@string/desc_music_dir_delete"
app:icon="@drawable/ic_delete_24" app:icon="@drawable/ic_delete_24"

View file

@ -106,12 +106,12 @@
tools:layout="@layout/dialog_pre_amp" /> tools:layout="@layout/dialog_pre_amp" />
<dialog <dialog
android:id="@+id/music_dirs_dialog" android:id="@+id/music_dirs_dialog"
android:name="org.oxycblt.auxio.music.storage.MusicDirsDialog" android:name="org.oxycblt.auxio.music.filesystem.MusicDirsDialog"
android:label="music_dirs_dialog" android:label="music_dirs_dialog"
tools:layout="@layout/dialog_music_dirs" /> tools:layout="@layout/dialog_music_dirs" />
<dialog <dialog
android:id="@+id/separators_dialog" android:id="@+id/separators_dialog"
android:name="org.oxycblt.auxio.music.extractor.SeparatorsDialog" android:name="org.oxycblt.auxio.music.parsing.SeparatorsDialog"
android:label="music_dirs_dialog" android:label="music_dirs_dialog"
tools:layout="@layout/dialog_separators" /> tools:layout="@layout/dialog_separators" />

View file

@ -268,4 +268,6 @@
<string name="lbl_shuffle_selected">Náhodně přehrát vybrané</string> <string name="lbl_shuffle_selected">Náhodně přehrát vybrané</string>
<string name="set_playback_mode_genre">Přehrát z žánru</string> <string name="set_playback_mode_genre">Přehrát z žánru</string>
<string name="lbl_wiki">Wiki</string> <string name="lbl_wiki">Wiki</string>
<string name="fmt_list">%1$s, %2$s</string>
<string name="lbl_reset">Obnovit</string>
</resources> </resources>

View file

@ -259,4 +259,5 @@
<string name="fmt_selected">%d ausgewählt</string> <string name="fmt_selected">%d ausgewählt</string>
<string name="set_playback_mode_genre">Vom Genre abspielen</string> <string name="set_playback_mode_genre">Vom Genre abspielen</string>
<string name="lbl_wiki">Wiki</string> <string name="lbl_wiki">Wiki</string>
<string name="fmt_list">%1$s, %2$s</string>
</resources> </resources>

View file

@ -128,12 +128,10 @@
<string name="fmt_lib_song_count">Canciones cargadas: %d</string> <string name="fmt_lib_song_count">Canciones cargadas: %d</string>
<plurals name="fmt_song_count"> <plurals name="fmt_song_count">
<item quantity="one">%d canción</item> <item quantity="one">%d canción</item>
<item quantity="many">%d canciones</item>
<item quantity="other">%d canciones</item> <item quantity="other">%d canciones</item>
</plurals> </plurals>
<plurals name="fmt_album_count"> <plurals name="fmt_album_count">
<item quantity="one">%d álbum</item> <item quantity="one">%d álbum</item>
<item quantity="many">%d álbumes</item>
<item quantity="other">%d álbumes</item> <item quantity="other">%d álbumes</item>
</plurals> </plurals>
<string name="lbl_size">Tamaño</string> <string name="lbl_size">Tamaño</string>
@ -263,4 +261,5 @@
<string name="lbl_play_selected">Reproducir los seleccionados</string> <string name="lbl_play_selected">Reproducir los seleccionados</string>
<string name="set_playback_mode_genre">Reproducir desde el género</string> <string name="set_playback_mode_genre">Reproducir desde el género</string>
<string name="lbl_wiki">Wiki</string> <string name="lbl_wiki">Wiki</string>
<string name="fmt_list">%1$s, %2$s</string>
</resources> </resources>

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