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
5. Copy and paste the output to this area of the issue.
render: shell
validations:
required: true
- type: checkboxes
id: terms
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
## dev
## 3.0.1
#### What's New
- Added support for album date ranges (ex. 2010 - 2013)
#### What's Improved
- Formalized whitespace handling
- Value lists are now properly localized
- Queue no longer primarily shows previous songs when opened
- Added reset button to ReplayGain pre-amp configuration dialog
#### What's Changed
- R128 ReplayGain tags are now only used when playing OPUS files
#### What's Fixed
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
- Fixed crash when playing certain MP3 files
- Detail UI will no longer crash if the music library is unavailable
#### Dev/Meta
- Add CI workflow
## 3.0.0
@ -12,7 +32,7 @@
- Added setting to hide "collaborator" artists
- Upgraded music ID management:
- Added support for MusicBrainz IDs (MBIDs)
- Use the more unique MD5 hash of metadata when MBIDs can't be used
- Use a more unique hash of metadata when MBIDs can't be used
- Genres now display a list of artists
- Added toggle to load non-music (Such as podcasts)
- Music loader now caches parsed metadata for faster load times
@ -42,7 +62,6 @@ audio focus was lost
#### What's Changed
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
- Removed the "Play from genre" option in the library/detail playback mode settings+
- "Use alternate notification action" is now "Custom notification action"
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"

View file

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

View file

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

View file

@ -21,5 +21,5 @@
#-renamesourcefileattribute SourceFile
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
# Also it's easier to debug if the class names remain unmangled.
# Also it's easier to fix issues if the stack trace symbols remain unmangled.
-dontobfuscate

View file

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

View file

@ -42,20 +42,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* TODO: Custom language support
*
* TODO: Add multi-select
*
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
*
* TODO: Migrate to material animation system
*
* TODO: Unit testing
*
* TODO: Standardize from/new usage
*
* TODO: Standardize companion object usage
*
* TODO: Standardize callback/listener naming.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MainActivity : AppCompatActivity() {
@ -146,7 +138,7 @@ class MainActivity : AppCompatActivity() {
return true
}
companion object {
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
private companion object {
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 org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
@ -62,10 +61,8 @@ class MainFragment :
private val selectionModel: SelectionViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
private var initialNavDestinationChange = true
private val elevationNormal: Float by lifecycleObject { binding ->
binding.context.getDimen(R.dimen.elevation_normal)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -78,6 +75,8 @@ class MainFragment :
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
// --- UI SETUP ---
val context = requireActivity()
// Override the back pressed listener so we can map back navigation to collapsing
@ -217,7 +216,7 @@ class MainFragment :
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
}
// Prevent interactions when the playback panell fully fades out.
// Prevent interactions when the playback panel fully fades out.
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
binding.queueSheet.apply {

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.filesystem.MimeType
/**
* A header variation that displays a button to open a sort menu.
@ -35,21 +35,13 @@ data class SortHeader(@StringRes val titleRes: Int) : Item
data class DiscHeader(val disc: Int) : Item
/**
* A [Song] extension that adds information about it's file properties.
* @param song The internal song
* @param properties The properties of the song file. Null if parsing is ongoing.
* The properties of a [Song]'s file.
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param sampleRateHz The sample rate, in hertz.
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
*/
data class DetailSong(val song: Song, val properties: Properties?) {
/**
* The properties of a [Song]'s file.
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param sampleRateHz The sample rate, in hertz.
* @param resolvedMimeType The known mime type of the [Song] after it's file format was
* determined.
*/
data class Properties(
val bitrateKbps: Int?,
val sampleRateHz: Int?,
val resolvedMimeType: MimeType
)
}
data class SongProperties(
val bitrateKbps: Int?,
val sampleRateHz: Int?,
val resolvedMimeType: MimeType
)

View file

@ -31,13 +31,13 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioAppBarLayout
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
/**
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
* beyond it's first item.
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.lazyReflectedField
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioAppBarLayout(context, attrs, defStyleAttr) {
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
@ -166,8 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
companion object {
private val TOOLBAR_TITLE_TEXT_FIELD: Field by
lazyReflectedField(Toolbar::class, "mTitleTextView")
private companion object {
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
}
}

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.filesystem.MimeType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.*
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback {
AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application)
@ -59,15 +59,15 @@ class DetailViewModel(application: Application) :
// --- SONG ---
private val _currentSong = MutableStateFlow<DetailSong?>(null)
/**
* The current [DetailSong] to display. Null if there is nothing to show.
*
* TODO: De-couple Song and Properties?
*/
val currentSong: StateFlow<DetailSong?>
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?>
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 ---
private val _currentAlbum = MutableStateFlow<Album?>(null)
@ -130,11 +130,11 @@ class DetailViewModel(application: Application) :
}
init {
musicStore.addCallback(this)
musicStore.addListener(this)
}
override fun onCleared() {
musicStore.removeCallback(this)
musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
@ -149,13 +149,8 @@ class DetailViewModel(application: Application) :
val song = currentSong.value
if (song != null) {
val newSong = library.sanitize(song.song)
if (newSong != null) {
loadDetailSong(newSong)
} else {
_currentSong.value = null
}
logD("Updated song to $newSong")
_currentSong.value = library.sanitize(song)?.also(::loadProperties)
logD("Updated song to ${currentSong.value}")
}
val album = currentAlbum.value
@ -178,17 +173,17 @@ class DetailViewModel(application: Application) :
}
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
* and [songProperties] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
if (_currentSong.value?.run { song.uid } == uid) {
if (_currentSong.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Song [uid: $uid]")
loadDetailSong(requireMusic(uid))
_currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
}
/**
@ -202,7 +197,7 @@ class DetailViewModel(application: Application) :
return
}
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
}
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
}
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 =
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
/**
* Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file.
* Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
* [songProperties].
* @param song The song to load.
*/
private fun loadDetailSong(song: Song) {
private fun loadProperties(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_currentSong.value = DetailSong(song, null)
_songProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = loadProperties(song)
val properties = this@DetailViewModel.loadPropertiesImpl(song)
yield()
_currentSong.value = DetailSong(song, info)
_songProperties.value = properties
}
}
private fun loadProperties(song: Song): DetailSong.Properties {
private fun loadPropertiesImpl(song: Song): SongProperties {
// While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
@ -266,7 +261,7 @@ class DetailViewModel(application: Application) :
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return DetailSong.Properties(null, null, song.mimeType)
return SongProperties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
@ -310,7 +305,7 @@ class DetailViewModel(application: Application) :
MimeType(song.mimeType.fromExtension, formatMimeType)
}
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
return SongProperties(bitrate, sampleRate, resolvedMimeType)
}
private fun refreshAlbumList(album: Album) {

View file

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

View file

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

View file

@ -54,9 +54,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.new(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.new(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.new(parent)
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@ -76,9 +76,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
return super.isItemFullWidth(position) || item is Artist
}
companion object {
private companion object {
/** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK =
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
@ -97,7 +97,7 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [new] to
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -156,7 +156,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
@ -172,7 +172,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
}
/**
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -184,12 +184,13 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu)
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date)
album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -210,20 +211,20 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<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.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -235,7 +236,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu)
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.album.resolveName(binding.context)
@ -259,7 +260,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */

View file

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

View file

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

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.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
@ -72,17 +65,7 @@ class HomeFragment :
private val homeModel: HomeViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
// lifecycleObject builds this in the creation step, so doing this is okay.
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.refresh()
}
}
private val sortItem: MenuItem by lifecycleObject { binding ->
binding.homeToolbar.menu.findItem(R.id.submenu_sorting)
}
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -105,6 +88,12 @@ class HomeFragment :
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// Have to set up the permission launcher before the view is shown
storagePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.refresh()
}
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(this)
@ -171,6 +160,7 @@ class HomeFragment :
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null)
}
@ -285,14 +275,16 @@ class HomeFragment :
}
}
val sortMenu = requireNotNull(sortItem.subMenu)
val sortMenu =
unlikelyToBeNull(
requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
// Check the ascending option and corresponding sort option to align with
// the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId ||
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
option.isChecked = true
}
@ -303,7 +295,13 @@ class HomeFragment :
// Update the scrolling view in AppBarLayout to align with the current tab's
// scrolling state. This prevents the lift state from being confused as one
// goes between different tabs.
requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode)
requireBinding().homeAppbar.liftOnScrollTargetViewId =
when (tabMode) {
MusicMode.SONGS -> R.id.home_song_recycler
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
}
}
private fun handleRecreate(recreate: Boolean) {
@ -321,9 +319,12 @@ class HomeFragment :
}
private fun updateIndexerState(state: Indexer.State?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
is Indexer.State.Complete -> setupCompleteState(binding, state.response)
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
null -> {
logD("Indexer is in indeterminate state")
@ -332,53 +333,56 @@ class HomeFragment :
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
if (response is Indexer.Response.Ok) {
private fun setupCompleteState(
binding: FragmentHomeBinding,
result: Result<MusicStore.Library>
) {
if (result.isSuccess) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
} else {
logD("Received non-ok response")
val context = requireContext()
val throwable = unlikelyToBeNull(result.exceptionOrNull())
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
when (response) {
is Indexer.Response.Err -> {
logD("Updating UI to Response.Err state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
}
is Indexer.Response.NoMusic -> {
logD("Updating UI to Response.NoMusic state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
}
is Indexer.Response.NoPerms -> {
logD("Updating UI to Response.NoPerms state")
when (throwable) {
is Indexer.NoPermissionException -> {
logD("Updating UI to permission request state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant)
setOnClickListener {
storagePermissionLauncher.launch(Indexer.PERMISSION_READ_AUDIO)
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(Indexer.PERMISSION_READ_AUDIO)
}
}
}
else -> {}
is Indexer.NoMusicException -> {
logD("Updating UI to no music state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
}
else -> {
logD("Updating UI to error state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
}
}
}
}
@ -438,10 +442,9 @@ class HomeFragment :
val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
// Significant enough change where we want to expand the RecyclerView
binding.homeAppbar.expandWithRecycler(
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
binding.homeAppbar.expandWithScrollingRecycler()
}
}
@ -457,20 +460,6 @@ class HomeFragment :
reenterTransition = MaterialSharedAxis(axis, false)
}
/**
* Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
* [MusicMode].
* @param tabMode The [MusicMode] of the tab.
* @return The ID of the RecyclerView contained by the given tab.
*/
private fun getTabRecyclerId(tabMode: MusicMode) =
when (tabMode) {
MusicMode.SONGS -> R.id.home_song_recycler
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
}
/**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
* @param tabs The current tab configuration. This will define the [Fragment]s created.
@ -493,12 +482,10 @@ class HomeFragment :
}
}
companion object {
private val VP_RECYCLER_FIELD: Field by
lazyReflectedField(ViewPager2::class, "mRecyclerView")
private val RV_TOUCH_SLOP_FIELD: Field by
lazyReflectedField(RecyclerView::class, "mTouchSlop")
private const val KEY_LAST_TRANSITION_AXIS =
private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
const val KEY_LAST_TRANSITION_AXIS =
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
}
}

View file

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

View file

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

View file

@ -71,26 +71,6 @@ class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
/** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider {
/**
* Get text to use in the popup at the specified position.
* @param pos The position in the list.
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
* at [pos].
*/
fun getPopup(pos: Int): String?
}
/** A listener for fast scroller interactions. */
interface Listener {
/**
* Called when the fast scrolling state changes.
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun onFastScrollingChanged(isFastScrolling: Boolean)
}
// Thumb
private val thumbView =
View(context).apply {
@ -524,7 +504,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
else -> 0
}
companion object {
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
/** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider {
/**
* Get text to use in the popup at the specified position.
* @param pos The position in the list.
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
* at [pos].
*/
fun getPopup(pos: Int): String?
}
/** A listener for fast scroller interactions. */
interface Listener {
/**
* Called when the fast scrolling state changes.
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun onFastScrollingChanged(isFastScrolling: Boolean)
}
private companion object {
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@
package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE
@ -25,7 +26,7 @@ import org.oxycblt.auxio.util.logE
* @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class Tab(open val mode: MusicMode) {
sealed class Tab(open val mode: MusicMode) : Item {
/**
* A visible tab. This will be visible in the home and tab configuration views.
* @param mode The type of list in the home view this instance corresponds to.

View file

@ -18,27 +18,28 @@
package org.oxycblt.auxio.home.tabs
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
* @param listener A [Listener] for tab interactions.
* @param listener A [EditableListListener] for tab interactions.
*/
class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewHolder>() {
class TabAdapter(private val listener: EditableListListener) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
private set
override fun getItemCount() = tabs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.from(parent)
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position], listener)
}
@ -75,30 +76,13 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewH
notifyItemMoved(a, b)
}
/** A listener for interactions specific to tab configuration. */
interface Listener {
/**
* 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()
private companion object {
val PAYLOAD_TAB_CHANGED = Any()
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
@ -106,12 +90,11 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
/**
* Bind new data to this instance.
* @param tab The new [Tab] to bind.
* @param listener A [TabAdapter.Listener] to bind interactions to.
* @param listener A [EditableListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: TabAdapter.Listener) {
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
fun bind(tab: Tab, listener: EditableListListener) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode
setText(
@ -126,15 +109,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
// the tab data since they are in the same data structure (Tab)
isChecked = tab is Tab.Visible
}
// Set up the drag handle to start a drag whenever it is touched.
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUp(this)
true
} else false
}
}
companion object {
@ -143,6 +117,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
fun from(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
}
}

View file

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

View file

@ -65,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float
init {
// Obtain some StyledImageView attributes to use later when theming the cusotm view.
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
// Keep track of our corner radius so that we can apply the same attributes to the custom
@ -107,7 +107,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Playback indicator should sit above the inner StyledImageView and custom view/
addView(playbackIndicatorView)
// Selction indicator should never be obscured, so place it at the top.
// Selection indicator should never be obscured, so place it at the top.
addView(
selectionIndicatorView,
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {

View file

@ -177,6 +177,8 @@ object Covers {
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
// Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
return withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(album.coverUri)
}
}
}

View file

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

View file

@ -17,8 +17,8 @@
package org.oxycblt.auxio.list
import android.view.MotionEvent
import android.view.View
import android.widget.Button
import androidx.recyclerview.widget.RecyclerView
/**
@ -26,13 +26,63 @@ import androidx.recyclerview.widget.RecyclerView
* @author Alexander Capehart (OxygenCobalt)
*/
interface ClickableListListener {
// TODO: Supply a ViewHolder on clicks
// (allows editable lists to be standardized into a listener.)
/**
* Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
*/
fun onClick(item: Item)
fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
* @param item The [Item] that this list entry is bound to.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
*/
fun bind(
item: Item,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView
) {
bodyView.setOnClickListener { onClick(item, viewHolder) }
}
}
/**
* An extension of [ClickableListListener] that enables list editing functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener : ClickableListListener {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
* @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
* @param item The [Item] that this list entry is bound to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
*/
fun bind(
item: Item,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView,
dragHandle: View
) {
bind(item, viewHolder, bodyView)
dragHandle.setOnTouchListener { _, motionEvent ->
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
}
}
/**
@ -55,19 +105,23 @@ interface SelectableListListener : ClickableListListener {
/**
* Binds this instance to a list item.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param item The [Item] that this list entry is bound to.
* @param menuButton A [Button] that opens a menu.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
*/
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
viewHolder.itemView.apply {
// Map clicks to the click listener.
setOnClickListener { onClick(item) }
// Map long clicks to the selection listener.
setOnLongClickListener {
onSelect(item)
true
}
fun bind(
item: Item,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView,
menuButton: View
) {
bind(item, viewHolder, bodyView)
// Map long clicks to the selection listener.
bodyView.setOnLongClickListener {
onSelect(item)
true
}
// Map the menu button to the menu opening listener.
menuButton.setOnClickListener { onOpenMenu(item, it) }

View file

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

View file

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

View file

@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
@ -46,7 +46,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(this, song, binding.songMenu)
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.resolveArtistContents(binding.context)
@ -70,7 +70,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
fun from(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -82,7 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
}
/**
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
@ -93,7 +93,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(this, album, binding.parentMenu)
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text = album.resolveArtistContents(binding.context)
@ -117,7 +117,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
fun from(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -131,7 +131,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
}
/**
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
@ -142,7 +142,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: SelectableListListener) {
listener.bind(this, artist, binding.parentMenu)
listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
binding.parentInfo.text =
@ -175,7 +175,8 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
fun from(parent: View) =
ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -189,7 +190,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
}
/**
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
@ -200,7 +201,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: SelectableListListener) {
listener.bind(this, genre, binding.parentMenu)
listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
binding.parentInfo.text =
@ -228,7 +229,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
fun from(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -240,7 +241,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
}
/**
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
@ -262,7 +263,8 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
fun from(parent: View) =
HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =

View file

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

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.text.CollationKey
import java.text.Collator
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.extractor.parseId3GenreNames
import org.oxycblt.auxio.music.extractor.parseMultiValue
import org.oxycblt.auxio.music.extractor.toUuidOrNull
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.music.filesystem.*
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -114,6 +110,27 @@ sealed class Music : Item {
return COLLATOR.getCollationKey(sortName)
}
/**
* Join a list of [Music]'s resolved names into a string in a localized manner, using
* [R.string.fmt_list].
* @param context [Context] required to obtain localized formatting.
* @param values The list of [Music] to format.
* @return A single string consisting of the values delimited by a localized separator.
*/
protected fun resolveNames(context: Context, values: List<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
// but UID are treated differently.
@ -262,9 +279,9 @@ sealed class Music : Item {
}
}
companion object {
private companion object {
/** Cached collator instance re-used with [makeCollationKeyImpl]. */
private val COLLATOR = Collator.getInstance().apply { strength = Collator.PRIMARY }
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
}
}
@ -399,9 +416,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter.
*/
fun resolveArtistContents(context: Context) =
// TODO Internationalize the list
artists.joinToString { it.resolveName(context) }
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
@ -433,7 +448,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
* Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
// --- INTERNAL FIELDS ---
@ -504,7 +519,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
// TODO: Make sure this works for artists only derived from album artists.
val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
@ -610,11 +624,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName
/**
* The earliest [Date] this album was released. Will be null if no valid date was present in the
* metadata of any [Song]
*/
val date: Date? // TODO: Date ranges?
/** The [Date.Range] that [Song]s in the [Album] were released. */
val dates = Date.Range.from(songs.mapNotNull { it.date })
/**
* The [Type] of this album, signifying the type of release it actually is. Defaults to
@ -634,31 +645,18 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
val dateAdded: Long
init {
var earliestDate: Date? = null
var totalDuration: Long = 0
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in songs) {
song._link(this)
if (song.date != null) {
// Since we can't really assign a maximum value for dates, we instead
// just check if the current earliest date doesn't exist and fill it
// in with the current song if that's the case.
if (earliestDate == null || song.date < earliestDate) {
earliestDate = song.date
}
}
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
date = earliestDate
durationMs = totalDuration
dateAdded = earliestDateAdded
}
@ -676,7 +674,7 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveArtistContents(context: Context) = artists.joinToString { it.resolveName(context) }
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
@ -1043,7 +1041,7 @@ class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicP
* Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
/**
* Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
@ -1212,181 +1210,20 @@ class Genre constructor(private val raw: Raw, override val songs: List<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 ---
/**
* Convert a [String] to a [UUID].
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
* @see UUID.fromString
*/
fun String.toUuidOrNull(): UUID? =
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}
/**
* Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,17 +27,20 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.directoryCompat
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.filesystem.safeQuery
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
import org.oxycblt.auxio.music.filesystem.useQuery
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
/**
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the
@ -302,7 +305,7 @@ abstract class MediaStoreExtractor(
// MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
raw.date = cursor.getIntOrNull(yearIndex)?.toDate()
raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to
@ -322,12 +325,12 @@ abstract class MediaStoreExtractor(
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
}
companion object {
private companion object {
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
*/
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
/**
* The album artist of a song. This column has existed since at least API 21, but until API
@ -335,13 +338,13 @@ abstract class MediaStoreExtractor(
* versions that Auxio supports.
*/
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* The external volume. This naming has existed since API 21, but no constant existed for it
* until API 29. This will work on all versions that Auxio supports.
*/
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
@Suppress("InlinedApi") const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
}
}
@ -561,7 +564,24 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it }
}
}
/**
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
* @return The track number extracted from the combined integer value, or null if the value was
* zero.
*/
private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
/**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
* @return The disc number extracted from the combined integer field, or null if the value was zero.
*/
private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()

View file

@ -21,12 +21,10 @@ import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.music.filesystem.toAudioUri
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -116,8 +114,8 @@ class MetadataExtractor(
}
}
companion object {
private const val TASK_CAPACITY = 8
private companion object {
const val TASK_CAPACITY = 8
}
}
@ -128,7 +126,6 @@ class MetadataExtractor(
* @author Alexander Capehart (OxygenCobalt)
*/
class Task(context: Context, private val raw: Song.Raw) {
// TODO: Unify with MetadataExtractor
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
@ -144,6 +141,7 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
fun get(): Song.Raw? {
if (!future.isDone) {
// Not done yet, nothing to do.
return null
}
@ -162,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
val metadata = format.metadata
if (metadata != null) {
populateWithMetadata(metadata)
val tags = Tags(metadata)
populateWithId3v2(tags.id3v2)
populateWithVorbis(tags.vorbis)
} else {
logD("No metadata could be extracted for ${raw.name}")
}
@ -170,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
return raw
}
/**
* Complete this instance's [Song.Raw] with the newly extracted [Metadata].
* @param metadata The [Metadata] to complete the [Song.Raw] with.
*/
private fun populateWithMetadata(metadata: Metadata) {
val id3v2Tags = mutableMapOf<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.
* @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>>) {
// Song
textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] }
textFrames["TIT2"]?.let { raw.name = it[0] }
textFrames["TSOT"]?.let { raw.sortName = it[0] }
// Track. Only parse out the track number and ignore the total tracks value.
textFrames["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it }
// Disc. Only parse out the disc number and ignore the total discs value.
textFrames["TPOS"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -241,27 +196,27 @@ class Task(context: Context, private val raw: Song.Raw) {
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { get(0).parseTimestamp() }
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() }
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() }
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { raw.date = it }
// Album
textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] }
textFrames["TALB"]?.let { raw.albumName = it[0] }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
(textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.albumTypes = it
}
// Artist
textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TPE1"]?.let { raw.artistNames = it }
textFrames["TSOP"]?.let { raw.artistSortNames = it }
// Album artist
textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
@ -282,8 +237,8 @@ class Task(context: Context, private val raw: Song.Raw) {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
textFrames["TORY"]?.run { get(0).toIntOrNull() }
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
@ -317,17 +272,17 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
comments["TITLE"]?.let { raw.name = it[0] }
comments["TITLESORT"]?.let { raw.sortName = it[0] }
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
comments["title"]?.let { raw.name = it[0] }
comments["titlesort"]?.let { raw.sortName = it[0] }
// Track. The total tracks value is in a different comment, so we can just
// convert the entirety of this comment into a number.
comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it }
// Disc. The total discs value is in a different comment, so we can just
// convert the entirety of this comment into a number.
comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -335,35 +290,28 @@ class Task(context: Context, private val raw: Song.Raw) {
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!)
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
?: comments["DATE"]?.run { get(0).parseTimestamp() }
?: comments["YEAR"]?.run { get(0).parseYear() })
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
?.let { raw.date = it }
// Album
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
comments["ALBUM"]?.let { raw.albumName = it[0] }
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
comments["album"]?.let { raw.albumName = it[0] }
comments["albumsort"]?.let { raw.albumSortName = it[0] }
comments["releasetype"]?.let { raw.albumTypes = it }
// Artist
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
comments["ARTIST"]?.let { raw.artistNames = it }
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
comments["artist"]?.let { raw.artistNames = it }
comments["artistsort"]?.let { raw.artistSortNames = it }
// Album artist
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["albumartist"]?.let { raw.albumArtistNames = it }
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
// Genre
comments["GENRE"]?.let { raw.genreNames = it }
}
/**
* Copies and sanitizes a possibly native/non-UTF-8 string.
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
* the Unicode replacement byte sequence.
*/
private fun String.sanitize() = String(encodeToByteArray())
}

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/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.filesystem
import android.view.View
import android.view.ViewGroup
@ -41,7 +41,7 @@ class DirectoryAdapter(private val listener: Listener) :
override fun getItemCount() = dirs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
MusicDirViewHolder.new(parent)
MusicDirViewHolder.from(parent)
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener)
@ -86,7 +86,7 @@ class DirectoryAdapter(private val listener: Listener) :
}
/**
* A [RecyclerView.Recycler] that displays a [Directory]. Use [new] to create an instance.
* A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
@ -107,7 +107,7 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
}
}

View file

@ -15,15 +15,13 @@
* 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.media.MediaExtractor
import android.media.MediaFormat
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.webkit.MimeTypeMap
import com.google.android.exoplayer2.util.MimeTypes
import java.io.File
import org.oxycblt.auxio.R

View file

@ -15,7 +15,7 @@
* 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.content.ContentResolver

View file

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

View file

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

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

View file

@ -39,7 +39,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = artists.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistChoiceViewHolder.new(parent)
ArtistChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
holder.bind(artists[position], listener)
@ -58,7 +58,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
/**
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for
* use with [ArtistChoiceAdapter]. Use [new] to create an instance.
* use with [ArtistChoiceAdapter]. Use [from] to create an instance.
*/
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
@ -68,7 +68,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: ClickableListListener) {
binding.root.setOnClickListener { listener.onClick(artist) }
listener.bind(artist, this)
binding.pickerImage.bind(artist)
binding.pickerName.text = artist.resolveName(binding.context)
}
@ -79,7 +79,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
}
}

View file

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

View file

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

View file

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

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
import android.view.View
@ -22,7 +39,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
override fun getItemCount() = genres.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreChoiceViewHolder.new(parent)
GenreChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
holder.bind(genres[position], listener)
@ -41,7 +58,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
/**
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for
* use with [GenreChoiceAdapter]. Use [new] to create an instance.
* use with [GenreChoiceAdapter]. Use [from] to create an instance.
*/
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
@ -51,7 +68,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: ClickableListListener) {
binding.root.setOnClickListener { listener.onClick(genre) }
listener.bind(genre, this)
binding.pickerImage.bind(genre)
binding.pickerName.text = genre.resolveName(binding.context)
}
@ -62,7 +79,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
}
}

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
import android.os.Bundle
@ -6,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener
@ -21,7 +39,8 @@ import org.oxycblt.auxio.util.collectImmediately
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
class GenrePlaybackPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
private val pickerModel: PickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
// Information about what Song to show choices for is initially within the navigation arguments
@ -56,11 +75,11 @@ class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBin
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.
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
playbackModel.playFromGenre(song, item)
}
}
}

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.system
import android.app.Service
import android.content.Intent
import android.content.SharedPreferences
import android.database.ContentObserver
import android.os.Handler
import android.os.IBinder
@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings
@ -54,7 +55,8 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexerService : Service(), Indexer.Controller, Settings.Callback {
class IndexerService :
Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
@ -81,7 +83,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
settings = Settings(this, this)
settings = Settings(this)
settings.addListener(this)
indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music.
@ -105,7 +108,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
settings.release()
settings.removeListener(this)
indexer.unregisterController(this)
// Then cancel any remaining music loading jobs.
serviceJob.cancel()
@ -126,11 +129,11 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onIndexerStateChanged(state: Indexer.State?) {
when (state) {
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
is Indexer.State.Complete -> {
if (state.response is Indexer.Response.Ok &&
state.response.library != musicStore.library) {
val newLibrary = state.result.getOrNull()
if (newLibrary != null && newLibrary != musicStore.library) {
logD("Applying new library")
val newLibrary = state.response.library
// We only care if the newly-loaded library is going to replace a previously
// loaded library.
if (musicStore.library != null) {
@ -149,9 +152,6 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// handled right now.
updateIdleSession()
}
is Indexer.State.Indexing -> {
updateActiveSession(state.indexing)
}
null -> {
// Null is the indeterminate state that occurs on app startup or after
// the cancellation of a load, so in that case we want to stop foreground
@ -195,7 +195,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
// this anymore.
// this anymore, or at least I only have to use it when the app task is not removed.
if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post()
}
@ -230,7 +230,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// --- SETTING CALLBACKS ---
override fun onSettingChanged(key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
// Hook changes in music settings to a new music loading event.
getString(R.string.set_key_exclude_non_music),
@ -287,8 +287,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
}
}
companion object {
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
private const val REINDEX_DELAY_MS = 500L
private companion object {
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
const val REINDEX_DELAY_MS = 500L
}
}

View file

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

View file

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

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
@ -27,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer
@ -38,10 +38,11 @@ import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that shows an editable list of queue items.
* @param listener A [Listener] to bind interactions to.
* @param listener A [EditableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueSongViewHolder>() {
class QueueAdapter(private val listener: EditableListListener) :
RecyclerView.Adapter<QueueSongViewHolder>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation
@ -52,7 +53,7 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
QueueSongViewHolder.new(parent)
QueueSongViewHolder.from(parent)
override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) =
throw IllegalStateException()
@ -121,29 +122,13 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
}
}
/** A listener for queue list events. */
interface Listener {
/**
* 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()
private companion object {
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.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -190,26 +175,17 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
/**
* Bind new data to this instance.
* @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")
fun bind(song: Song, listener: QueueAdapter.Listener) {
binding.body.setOnClickListener { listener.onClick(this) }
fun bind(song: Song, listener: EditableListListener) {
listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(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
// 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) {
@ -223,7 +199,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
fun from(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */

View file

@ -24,7 +24,10 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@ -36,13 +39,11 @@ import org.oxycblt.auxio.util.logD
* A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.Listener {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(QueueDragCallback(queueModel))
}
private var touchHelper: ItemTouchHelper? = null
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
@ -52,7 +53,10 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
// --- UI SETUP ---
binding.queueRecycler.apply {
adapter = queueAdapter
touchHelper.attachToRecyclerView(this)
touchHelper =
ItemTouchHelper(QueueDragCallback(queueModel)).also {
it.attachToRecyclerView(this)
}
}
// Sometimes the scroll can change without the listener being updated, so we also
@ -77,13 +81,12 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
binding.queueRecycler.adapter = null
}
override fun onClick(viewHolder: RecyclerView.ViewHolder) {
// Clicking on a queue item should start playing it.
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
queueModel.goto(viewHolder.bindingAdapterPosition)
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder)
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
private fun updateDivider() {
@ -108,17 +111,25 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
queueModel.finishReplace()
// If requested, scroll to a new item (occurs when the index moves)
// TODO: Scroll to center/top instead of bottom
val scrollTo = queueModel.scrollTo
if (scrollTo != null) {
// Do not scroll to indices that are in the currently visible range. As that would
// lead to the queue jumping around every time goto is called.
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
val end = lmm.findLastCompletelyVisibleItemPosition()
if (scrollTo !in start..end) {
logD("Scrolling to new position")
val notInitialized =
start == RecyclerView.NO_POSITION || end == RecyclerView.NO_POSITION
// When we scroll, we want to scroll to the almost-top so the user can see
// future songs instead of past songs. The way we have to do this however is
// dependent on where we have to scroll to get to the currently playing song.
if (notInitialized || scrollTo < start) {
// We need to scroll upwards, or initialize the scroll, no need to offset
binding.queueRecycler.scrollToPosition(scrollTo)
} else if (scrollTo > end) {
// We need to scroll downwards, we need to offset by a screen of songs.
// This does have some error due to what the layout manager returns being
// somewhat mutable. This is considered okay.
binding.queueRecycler.scrollToPosition(
min(queue.lastIndex, scrollTo + (end - start)))
}
}
queueModel.finishScrollTo()

View file

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

View file

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

View file

@ -18,33 +18,38 @@
package org.oxycblt.auxio.playback.replaygain
import android.content.Context
import android.content.SharedPreferences
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Tracks
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer
import kotlin.math.pow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.extractor.Tags
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* An [AudioProcessor] that handles ReplayGain values and their amplification of the audio stream.
* Instead of leveraging the volume attribute like other implementations, this system manipulates
* the bitstream itself to modify the volume, which allows the use of positive ReplayGain values.
*
* Note: This instance must be updated with a new [Metadata] every time the active track chamges.
* Note: This audio processor must be attached to a respective [Player] instance as a
* [Player.Listener] to function properly.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
class ReplayGainAudioProcessor(private val context: Context) :
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context)
private var lastFormat: Format? = null
private var volume = 1f
set(value) {
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
flush()
}
/**
* Add this instance to the components required for it to function correctly.
* @param player The [Player] to attach to. Should already have this instance as an audio
* processor.
*/
fun addToListeners(player: Player) {
player.addListener(this)
settings.addListener(this)
}
/**
* Remove this instance from the components required for it to function correctly.
* @param player The [Player] to detach from. Should already have this instance as an audio
* processor.
*/
fun releaseFromListeners(player: Player) {
player.removeListener(this)
settings.removeListener(this)
}
// --- OVERRIDES ---
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
// Try to find the currently playing track so we can update the ReplayGain adjustment
// based on it.
for (group in tracks.groups) {
if (group.isSelected) {
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) {
applyReplayGain(group.getTrackFormat(i))
return
}
}
}
}
// Nothing selected, apply nothing
applyReplayGain(null)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == context.getString(R.string.set_key_replay_gain) ||
key == context.getString(R.string.set_key_pre_amp_with) ||
key == context.getString(R.string.set_key_pre_amp_without)) {
// ReplayGain changed, we need to set it up again.
applyReplayGain(lastFormat)
}
}
// --- REPLAYGAIN PARSING ---
/**
* Updates the volume adjustment based on the given [Metadata].
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
* [Metadata].
* Updates the volume adjustment based on the given [Format].
* @param format The [Format] of the currently playing track, or null if nothing is playing.
*/
fun applyReplayGain(metadata: Metadata?) {
// TODO: Allow this to automatically obtain it's own [Metadata].
val gain = metadata?.let(::parseReplayGain)
private fun applyReplayGain(format: Format?) {
lastFormat = format
val gain = parseReplayGain(format ?: return)
val preAmp = settings.replayGainPreAmp
val adjust =
if (gain != null) {
logD("Found ReplayGain adjustment $gain")
// ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain =
when (settings.replayGainMode) {
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
}
/**
* Parse ReplayGain information from the given [Metadata].
* @param metadata The [Metadata] to parse.
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
* Parse ReplayGain information from the given [Format].
* @param format The [Format] to parse.
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
*/
private fun parseReplayGain(metadata: Metadata): Gain? {
// TODO: Unify this parser with the music parser? They both grok Metadata.
private fun parseReplayGain(format: Format): Adjustment? {
val tags = Tags(format.metadata ?: return null)
var trackGain = 0f
var albumGain = 0f
var found = false
val tags = mutableListOf<GainTag>()
for (i in 0 until metadata.length()) {
val entry = metadata.get(i)
val key: String?
val value: String
when (entry) {
// ID3v2 text information frame, usually these are formatted in lowercase
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
// capitalization is consistent before continuing.
is TextInformationFrame -> {
key = entry.description
value = entry.values[0]
}
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
// frame by ExoPlayer (presumably to reduce duplication).
is InternalFrame -> {
key = entry.description
value = entry.text
}
// Vorbis comment. These are nearly always uppercase, so a check for such is
// skipped.
is VorbisComment -> {
key = entry.key
value = entry.value
}
else -> continue
}
if (key in REPLAY_GAIN_TAGS) {
// Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
// or -.
// Derived from vanilla music: https://github.com/vanilla-music/vanilla
val gainValue =
try {
value.replace(Regex("[^\\d.-]"), "").toFloat()
} catch (e: Exception) {
0f
}
tags.add(GainTag(unlikelyToBeNull(key), gainValue))
}
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
// replaygain_*_gain tag.
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
tags.vorbis[TAG_RG_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.vorbis[TAG_RG_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
} else {
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
// adjustment by 256 to get the gain. This is used alongside the base adjustment
// intrinsic to the format to create the normalized adjustment. That base adjustment
// is already handled by the media framework, so we just need to apply the more
// specific adjustments.
tags.vorbis[TAG_R128_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it / 256f }
tags.vorbis[TAG_R128_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it / 256f }
}
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
tags
.findLast { tag -> tag.key.equals(TAG_RG_TRACK, ignoreCase = true) }
?.let { tag ->
trackGain = tag.value
found = true
}
tags
.findLast { tag -> tag.key.equals(TAG_RG_ALBUM, ignoreCase = true) }
?.let { tag ->
albumGain = tag.value
found = true
}
// Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless
// encodings to increase precision in volume adjustments.
// While technically there is the R128 base gain in Opus files, that is automatically
// applied by the media framework [which ExoPlayer relies on]. The only reason we would
// want to read it is to zero previous ReplayGain values for being invalid, however there
// is no demand to fix that edge case right now.
tags
.findLast { tag -> tag.key.equals(R128_TRACK, ignoreCase = true) }
?.let { tag ->
trackGain += tag.value / 256f
found = true
}
tags
.findLast { tag -> tag.key.equals(R128_ALBUM, ignoreCase = true) }
?.let { tag ->
albumGain += tag.value / 256f
found = true
}
return if (found) {
Gain(trackGain, albumGain)
return if (trackGain != 0f || albumGain != 0f) {
Adjustment(trackGain, albumGain)
} else {
null
}
}
/**
* Parse a ReplayGain adjustment into a float value.
* @return A parsed adjustment float, or null if the adjustment had invalid formatting.
*/
private fun String.parseReplayGainAdjustment() =
replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()
// --- AUDIO PROCESSOR IMPLEMENTATION ---
override fun onConfigure(
@ -271,22 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
* @param track The track adjustment (in dB), or 0 if it is not present.
* @param album The album adjustment (in dB), or 0 if it is not present.
*/
private data class Gain(val track: Float, val album: Float)
private data class Adjustment(val track: Float, val album: Float)
/**
* A raw ReplayGain adjustment.
* @param key The tag's key.
* @param value The tag's adjustment, in dB.
*/
private data class GainTag(val key: String, val value: Float)
// TODO: Try to phase this out
private companion object {
const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
const val TAG_R128_TRACK_GAIN = "r128_track_gain"
const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
companion object {
private const val TAG_RG_TRACK = "replaygain_track_gain"
private const val TAG_RG_ALBUM = "replaygain_album_gain"
private const val R128_TRACK = "r128_track_gain"
private const val R128_ALBUM = "r128_album_gain"
private val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
/**
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
* https://github.com/vanilla-music/vanilla
*/
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -152,14 +152,14 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
startActivity(chooserIntent)
}
companion object {
private companion object {
/** The URL to the source code. */
private const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
/** The URL to the app wiki. */
private const val LINK_WIKI = "$LINK_SOURCE/wiki"
const val LINK_WIKI = "$LINK_SOURCE/wiki"
/** The URL to the licenses wiki page. */
private const val LINK_LICENSES = "$LINK_WIKI/Licenses"
const val LINK_LICENSES = "$LINK_WIKI/Licenses"
/** The URL to the app author. */
private const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
}
}

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.settings
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate
@ -30,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.MusicDirectories
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
@ -40,20 +41,14 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Object
* mutability
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
* mutability is dependent on how they are used in app. Immutable members are often only modified by
* the preferences view, while mutable members are modified elsewhere.
* @author Alexander Capehart (OxygenCobalt)
*/
class Settings(private val context: Context, private val callback: Callback? = null) :
SharedPreferences.OnSharedPreferenceChangeListener {
class Settings(private val context: Context) {
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
init {
if (callback != null) {
inner.registerOnSharedPreferenceChangeListener(this)
}
}
/**
* Migrate any settings from an old version into their modern counterparts. This can cause data
* loss depending on the feasibility of a migration.
@ -154,27 +149,19 @@ class Settings(private val context: Context, private val callback: Callback? = n
}
/**
* Release this instance and any callbacks held by it. This is not needed if no [Callback] was
* originally attached.
* Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
* @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
*/
fun release() {
inner.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
unlikelyToBeNull(callback).onSettingChanged(key)
fun addListener(listener: OnSharedPreferenceChangeListener) {
inner.registerOnSharedPreferenceChangeListener(listener)
}
/**
* Simplified callback for settings changes.
* Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
* settings updates from being sent to ti.t
*/
interface Callback {
// TODO: Refactor this lifecycle
/**
* Called when a setting has changed.
* @param key The key of the setting that changed.
*/
fun onSettingChanged(key: String)
fun removeListener(listener: OnSharedPreferenceChangeListener) {
inner.unregisterOnSharedPreferenceChangeListener(listener)
}
// --- VALUES ---

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,11 +23,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -37,7 +34,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/**
* 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(
inflater: LayoutInflater,
container: ViewGroup?,
@ -119,9 +96,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
// Apply the newly-configured view to the dialog.
@ -132,21 +106,8 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
final override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null
logD("Fragment destroyed")
}
/** Internal implementation of [lifecycleObject]. */
private data class LifecycleObject<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 androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -34,7 +32,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/**
* 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(
inflater: LayoutInflater,
container: ViewGroup?,
@ -103,9 +80,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created")
@ -114,21 +88,8 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
final override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null
logD("Fragment destroyed")
}
/** Internal implementation of [lifecycleObject]. */
private data class LifecycleObject<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 onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccentViewHolder.new(parent)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AccentViewHolder.from(parent)
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
throw NotImplementedError()
@ -75,13 +76,13 @@ class AccentAdapter(private val listener: ClickableListListener) :
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
}
companion object {
private val PAYLOAD_SELECTION_CHANGED = Any()
private companion object {
val PAYLOAD_SELECTION_CHANGED = Any()
}
}
/**
* A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [new] to create an instance.
* A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) :
@ -93,13 +94,13 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(accent: Accent, listener: ClickableListListener) {
listener.bind(accent, this, binding.accent)
binding.accent.apply {
setOnClickListener { listener.onClick(accent) }
backgroundTintList = context.getColorCompat(accent.primary)
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
contentDescription = context.getString(accent.name)
TooltipCompat.setTooltipText(this, contentDescription)
backgroundTintList = context.getColorCompat(accent.primary)
}
}
@ -124,6 +125,7 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
fun from(parent: View) =
AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
}
}

View file

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

View file

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

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
* an adaptive layout, in a version-compatible manner.
* Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with an
* adaptive layout, in a version-compatible manner.
* @param context [Context] required to backport adaptive layout behavior.
* @param component [ComponentName] of the app widget layout to update.
* @param views Mapping between different size classes and [RemoteViews] instances.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -106,12 +106,12 @@
tools:layout="@layout/dialog_pre_amp" />
<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"
tools:layout="@layout/dialog_music_dirs" />
<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"
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="set_playback_mode_genre">Přehrát z žánru</string>
<string name="lbl_wiki">Wiki</string>
<string name="fmt_list">%1$s, %2$s</string>
<string name="lbl_reset">Obnovit</string>
</resources>

View file

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

View file

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

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