music: refactor name implementation

Refactor the music name implementation to do the following:
1. Unify normal and sort names under a single datatype
2. Handle arbitrary-length digit strings
3. Ignore puncutation regardless of the intelligent sort
configuration, as it is trivially localizable.

Resolves #423.

Co-authored by: ChatGPT-3.5
This commit is contained in:
Alexander Capehart 2023-05-11 12:16:26 -06:00
parent ca349dea18
commit c7b875376c
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
51 changed files with 384 additions and 255 deletions

View file

@ -194,7 +194,7 @@ class AlbumDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = album.resolveName(requireContext())
requireBinding().detailToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
}

View file

@ -204,7 +204,7 @@ class ArtistDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
requireBinding().detailToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
}

View file

@ -36,9 +36,9 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
@ -53,7 +53,7 @@ class DetailViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val audioInfoFactory: AudioInfo.Factory,
private val audioPropertiesFactory: AudioProperties.Factory,
private val musicSettings: MusicSettings,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.UpdateListener {
@ -66,9 +66,9 @@ constructor(
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
// --- ALBUM ---
@ -225,7 +225,7 @@ constructor(
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
* [songAudioInfo] will be updated to align with the new [Song].
* [songAudioProperties] will be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
@ -305,12 +305,12 @@ constructor(
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songAudioInfo.value = null
_songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioInfoFactory.extract(song)
val info = audioPropertiesFactory.extract(song)
yield()
_songAudioInfo.value = info
_songAudioProperties.value = info
}
}

View file

@ -196,7 +196,7 @@ class GenreDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
requireBinding().detailToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
}

View file

@ -187,7 +187,7 @@ class PlaylistDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = playlist.resolveName(requireContext())
requireBinding().detailToolbar.title = playlist.name.resolve(requireContext())
playlistHeaderAdapter.setParent(playlist)
}

View file

@ -34,7 +34,8 @@ import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
@ -67,10 +68,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.songUid)
collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
}
private fun updateSong(song: Song?, info: AudioInfo?) {
private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
@ -123,12 +124,14 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
}
}
private fun <T : Music> T.zipName(context: Context) =
if (rawSortName != null) {
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
private fun <T : Music> T.zipName(context: Context): String {
val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
resolveName(context)
name.resolve(context)
}
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }

View file

@ -77,7 +77,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
binding.detailName.text = album.name.resolve(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {

View file

@ -63,7 +63,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
binding.detailName.text = artist.name.resolve(binding.context)
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text

View file

@ -62,7 +62,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
binding.detailName.text = genre.name.resolve(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text.

View file

@ -62,7 +62,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(playlist)
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.resolveName(binding.context)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the playlist maps to the info text.

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
@ -171,7 +171,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
}
}
binding.songName.text = song.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
// Use duration instead of album or artist for each song, as this text would
// be homogenous otherwise.
@ -204,7 +204,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
oldItem.name == newItem.name && oldItem.durationMs == newItem.durationMs
}
}
}

View file

@ -106,7 +106,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context)
@ -139,7 +139,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
oldItem.name == newItem.name && oldItem.dates == newItem.dates
}
}
}
@ -161,8 +161,8 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
fun bind(song: Song, listener: SelectableListListener<Song>) {
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)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.album.name.resolve(binding.context)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -191,8 +191,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.album.rawName == newItem.album.rawName
oldItem.name == newItem.name && oldItem.album.name == newItem.album.name
}
}
}

View file

@ -94,10 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> album.sortName?.thumbString
is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// 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()) }

View file

@ -93,7 +93,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> artist.sortName?.thumbString
is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)

View file

@ -92,7 +92,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> genre.sortName?.thumbString
is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)

View file

@ -85,7 +85,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.sortName?.thumbString
is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)

View file

@ -100,13 +100,13 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.sortName?.thumbString
is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())

View file

@ -147,7 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(this)
imageLoader.enqueue(request)
// Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.resolveName(context))
contentDescription = context.getString(descRes, music.name.resolve(context))
}
/**

View file

@ -46,7 +46,6 @@ constructor(
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
suspend fun extract(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {

View file

@ -81,7 +81,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param song The [Song] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
logD("Launching new song menu: ${song.rawName}")
logD("Launching new song menu: ${song.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -120,7 +120,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param album The [Album] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
logD("Launching new album menu: ${album.rawName}")
logD("Launching new album menu: ${album.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -157,7 +157,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param artist The [Artist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
logD("Launching new artist menu: ${artist.rawName}")
logD("Launching new artist menu: ${artist.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -191,7 +191,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param genre The [Genre] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
logD("Launching new genre menu: ${genre.rawName}")
logD("Launching new genre menu: ${genre.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -225,7 +225,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param playlist The [Playlist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
logD("Launching new playlist menu: ${playlist.rawName}")
logD("Launching new playlist menu: ${playlist.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {

View file

@ -24,8 +24,8 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
/**
* A sorting method.
@ -566,16 +566,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Music.collationKey
*/
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T): Int {
val aKey = a.sortName
val bKey = b.sortName
return when {
aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b
aKey == null && bKey == null -> 0 // a = b
else -> 1 // a < b
}
}
override fun compare(a: T, b: T) = a.name.compareTo(b.name)
companion object {
/** A re-usable instance configured for [Song]s. */

View file

@ -51,7 +51,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
}
@ -80,8 +80,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists)
oldItem.name == newItem.name && oldItem.artists.areNamesTheSame(newItem.artists)
}
}
}
@ -102,7 +101,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = album.artists.resolveNames(binding.context)
}
@ -131,8 +130,8 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.name == newItem.name &&
oldItem.artists.areNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType
}
}
@ -154,7 +153,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
binding.parentName.text = artist.name.resolve(binding.context)
binding.parentInfo.text =
if (artist.songs.isNotEmpty()) {
binding.context.getString(
@ -193,7 +192,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
val DIFF_CALLBACK =
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.name == newItem.name &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
}
@ -216,7 +215,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
fun bind(genre: Genre, listener: SelectableListListener<Genre>) {
listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
binding.parentName.text = genre.name.resolve(binding.context)
binding.parentInfo.text =
binding.context.getString(
R.string.fmt_two,
@ -249,7 +248,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.rawName == newItem.rawName &&
oldItem.name == newItem.name &&
oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.size
}
@ -272,7 +271,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
fun bind(playlist: Playlist, listener: SelectableListListener<Playlist>) {
listener.bind(playlist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(playlist)
binding.parentName.text = playlist.resolveName(binding.context)
binding.parentName.text = playlist.name.resolve(binding.context)
binding.parentInfo.text =
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size)
}
@ -303,7 +302,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() {
override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean =
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size
}
}
}

View file

@ -22,8 +22,6 @@ import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.room.TypeConverter
import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
@ -31,9 +29,10 @@ import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull
@ -51,35 +50,8 @@ sealed interface Music : Item {
*/
val uid: UID
/**
* The raw name of this item as it was extracted from the file-system. Will be null if the
* item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
*/
val rawName: String?
/**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in
* nearly all cases.
*
* @param context [Context] required to obtain placeholder text or formatting information.
* @return A human-readable string representing the name of this music. In the case that the
* item does not have a name, an analogous "Unknown X" name is returned.
*/
fun resolveName(context: Context): String
/**
* The raw sort name of this item as it was extracted from the file-system. This can be used not
* only when sorting music, but also trying to locate music based on a fuzzy search by the user.
* Will be null if the item has no known sort name.
*/
val rawSortName: String?
/**
* A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
* sorting in the context of music. This should be preferred over [rawSortName] in most cases.
* Null if there are no [rawName] or [rawSortName] values to build on.
*/
val sortName: SortName?
/** The [Name] of the music item. */
val name: Name
/**
* A unique identifier for a piece of music.
@ -342,61 +314,6 @@ interface Playlist : MusicParent {
val durationMs: Long
}
/**
* A black-box datatype for a variation of music names that is suitable for music-oriented sorting.
* It will automatically handle articles like "The" and numeric components like "An".
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName> {
private val collationKey: CollationKey
val thumbString: String?
init {
var sortName = name
if (musicSettings.intelligentSorting) {
sortName = sortName.replace(LEADING_PUNCTUATION_REGEX, "")
sortName =
sortName.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
sortName = sortName.replace(CONSECUTIVE_DIGITS_REGEX) { it.value.padStart(6, '0') }
}
collationKey = COLLATOR.getCollationKey(sortName)
// Keep track of a string to use in the thumb view.
// Simply show '#' for everything before 'A'
// TODO: This needs to be moved elsewhere.
thumbString =
collationKey?.run {
val thumbChar = sourceString.firstOrNull()
if (thumbChar?.isLetter() == true) thumbChar.uppercase() else "#"
}
}
override fun toString(): String = collationKey.sourceString
override fun compareTo(other: SortName) = collationKey.compareTo(other.collationKey)
override fun equals(other: Any?) = other is SortName && collationKey == other.collationKey
override fun hashCode(): Int = collationKey.hashCode()
private companion object {
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
val LEADING_PUNCTUATION_REGEX = Regex("[\\p{Punct}+]")
val CONSECUTIVE_DIGITS_REGEX = Regex("\\d+")
}
}
/**
* Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
* in a localized manner.
@ -405,20 +322,20 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.resolveName(context) }
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.rawName] matches for each item in a list. Useful for scenarios where the
* display information of an item must be compared without a context.
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.rawName]), false otherwise.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areRawNamesTheSame(other: List<T>): Boolean {
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
if (a.name != b.name) {
return false
}
}

View file

@ -28,7 +28,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.music.device
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.util.*
@ -29,9 +28,8 @@ import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toCoverUri
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.info.*
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
import org.oxycblt.auxio.music.metadata.parseMultiValue
import org.oxycblt.auxio.util.nonZeroOrNull
@ -63,10 +61,11 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
update(rawSong.artistNames)
update(rawSong.albumArtistNames)
}
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val name =
Name.Known.from(
requireNotNull(rawSong.name) { "Invalid raw: No title" },
rawSong.sortName,
musicSettings)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
@ -239,10 +238,7 @@ class AlbumImpl(
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
@ -332,12 +328,11 @@ class ArtistImpl(
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: createHashedUid(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name
override val rawSortName = rawArtist.sortName
override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song>
override val name =
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
?: Name.Unknown(R.string.def_artist)
override val songs: List<Song>
override val albums: List<Album>
override val durationMs: Long?
override val isCollaborator: Boolean
@ -417,10 +412,9 @@ class GenreImpl(
override val songs: List<SongImpl>
) : Genre {
override val uid = createHashedUid(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name
override val rawSortName = rawName
override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
override val name =
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
?: Name.Unknown(R.string.def_genre)
override val albums: List<Album>
override val artists: List<Artist>

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.music.device
import java.util.UUID
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.*
/**

View file

@ -32,7 +32,7 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.cache.Cache
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.transformPositionField
import org.oxycblt.auxio.util.getSystemServiceCompat

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import android.content.Context
import java.text.ParseException

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.list.Item
@ -26,8 +26,6 @@ import org.oxycblt.auxio.list.Item
* @param number The disc number.
* @param name The name of the disc group, if any. Null if not present.
*/
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
override fun hashCode() = number.hashCode()
override fun equals(other: Any?) = other is Disc && number == other.number
data class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
override fun compareTo(other: Disc) = number.compareTo(other.number)
}

View file

@ -0,0 +1,217 @@
/*
* Copyright (c) 2023 Auxio Project
* Name.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.info
import android.content.Context
import androidx.annotation.StringRes
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.music.MusicSettings
/**
* The name of a music item.
*
* This class automatically implements
*
* @author Alexander Capehart
*/
sealed interface Name : Comparable<Name> {
/**
* A logical first character that can be used to collate a sorted list of music.
*
* TODO: Move this to the home view
*/
val thumb: String
/**
* Get a human-readable string representation of this instance.
*
* @param context [Context] required.
*/
fun resolve(context: Context): String
/** A name that could be obtained for the music item. */
sealed class Known : Name {
/** The raw name string obtained. Should be ignored in favor of [resolve]. */
abstract val raw: String
/** The raw sort name string obtained. */
abstract val sort: String?
/** A tokenized version of the name that will be compared. */
protected abstract val sortTokens: List<SortToken>
/** An individual part of a name string that can be compared intelligently. */
protected data class SortToken(val collationKey: CollationKey, val type: Type) :
Comparable<SortToken> {
override fun compareTo(other: SortToken): Int {
// Numeric tokens should always be lower than lexicographic tokens.
val modeComp = type.compareTo(other.type)
if (modeComp != 0) {
return modeComp
}
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
// the comparison if the lengths do not match.
if (type == Type.NUMERIC &&
collationKey.sourceString.length != other.collationKey.sourceString.length) {
return collationKey.sourceString.length - other.collationKey.sourceString.length
}
return collationKey.compareTo(other.collationKey)
}
/** Denotes the type of comparison to be performed with this token. */
enum class Type {
/** Compare as a digit string, like "65". */
NUMERIC,
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
LEXICOGRAPHIC
}
}
final override val thumb: String
get() =
// TODO: Remove these checks once you have real unit testing
sortTokens
.firstOrNull()
?.run { collationKey.sourceString.firstOrNull() }
?.let { if (it.isDigit()) "#" else it.uppercase() }
?: "?"
final override fun resolve(context: Context) = raw
final override fun compareTo(other: Name) =
when (other) {
is Known -> {
// Progressively compare the sort tokens between each known name.
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
}
}
// Unknown names always come before known names.
is Unknown -> 1
}
companion object {
/**
* Create a new instance of [Name.Known]
* @param raw The raw name obtained from the music item
* @param sort The raw sort name obtained from the music item
* @param musicSettings [MusicSettings] required to obtain user-preferred sorting
* configurations
*/
fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known =
if (musicSettings.intelligentSorting) {
IntelligentKnownName(raw, sort)
} else {
SimpleKnownName(raw, sort)
}
}
}
/**
* A placeholder name that is used when a [Known] name could not be obtained for the item.
*
* @author Alexander Capehart
*/
data class Unknown(@StringRes val stringRes: Int) : Name {
override val thumb = "?"
override fun resolve(context: Context) = context.getString(stringRes)
override fun compareTo(other: Name) =
when (other) {
// Unknown names do not need any direct comparison right now.
is Unknown -> 0
// Unknown names always come before known names.
is Known -> -1
}
}
}
private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private val PUNCT_REGEX = Regex("[\\p{Punct}+]")
/**
* Plain [Name.Known] implementation that is internationalization-safe.
* @author Alexander Capehart (OxygenCobalt)
*/
private data class SimpleKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = listOf(parseToken(sort ?: raw))
private fun parseToken(name: String): SortToken {
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
val stripped = name.replace(PUNCT_REGEX, "").ifEmpty { name }
val collationKey = COLLATOR.getCollationKey(stripped)
// Always use lexicographic mode since we aren't parsing any numeric components
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
}
}
/**
* [Name.Known] implementation that adds advanced sorting behavior at the cost of localization.
* @author Alexander Capehart (OxygenCobalt)
*/
private data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = parseTokens(sort ?: raw)
private fun parseTokens(name: String): List<SortToken> {
val stripped =
name
// Remove excess punctuation from the string, as those u
.replace(PUNCT_REGEX, "")
.ifEmpty { name }
.run {
// Strip any english articles like "the" or "an" from the start, as music
// sorting should ignore such when possible.
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
// To properly compare numeric components in names, we have to split them up into
// individual lexicographic and numeric tokens and then individually compare them
// with special logic.
return TOKEN_REGEX.findAll(stripped).mapTo(mutableListOf()) { match ->
// Remove excess whitespace where possible
val token = match.value.trim().ifEmpty { match.value }
val collationKey: CollationKey
val type: SortToken.Type
// Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those
val digits = token.trimStart('0').ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys
collationKey = COLLATOR.getCollationKey(digits)
type = SortToken.Type.NUMERIC
} else {
collationKey = COLLATOR.getCollationKey(token)
type = SortToken.Type.LEXICOGRAPHIC
}
SortToken(collationKey, type)
}
}
companion object {
private val TOKEN_REGEX = Regex("(\\d+)|(\\D+)")
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.R

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* AudioInfo.kt is part of Auxio.
* AudioProperties.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -37,32 +37,33 @@ import org.oxycblt.auxio.util.logW
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
* @author Alexander Capehart (OxygenCobalt)
*/
data class AudioInfo(
data class AudioProperties(
val bitrateKbps: Int?,
val sampleRateHz: Int?,
val resolvedMimeType: MimeType
) {
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
/** Implements the process of extracting [AudioProperties] from a given [Song]. */
interface Factory {
/**
* Extract the [AudioInfo] of a given [Song].
* Extract the [AudioProperties] of a given [Song].
*
* @param song The [Song] to read.
* @return The [AudioInfo] of the [Song], if possible to obtain.
* @return The [AudioProperties] of the [Song], if possible to obtain.
*/
suspend fun extract(song: Song): AudioInfo
suspend fun extract(song: Song): AudioProperties
}
}
/**
* A framework-backed implementation of [AudioInfo.Factory].
* A framework-backed implementation of [AudioProperties.Factory].
*
* @param context [Context] required to read audio files.
*/
class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) :
AudioInfo.Factory {
class AudioPropertiesFactoryImpl
@Inject
constructor(@ApplicationContext private val context: Context) : AudioProperties.Factory {
override suspend fun extract(song: Song): AudioInfo {
override suspend fun extract(song: Song): AudioProperties {
// 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.
@ -76,7 +77,7 @@ class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return AudioInfo(null, null, song.mimeType)
return AudioProperties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
@ -122,6 +123,6 @@ class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c
extractor.release()
return AudioInfo(bitrate, sampleRate, resolvedMimeType)
return AudioProperties(bitrate, sampleRate, resolvedMimeType)
}
}

View file

@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent
interface MetadataModule {
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
@Binds fun audioInfoProvider(factory: AudioInfoFactoryImpl): AudioInfo.Factory
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
}

View file

@ -27,6 +27,7 @@ import java.util.concurrent.Future
import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW

View file

@ -18,20 +18,17 @@
package org.oxycblt.auxio.music.user
import android.content.Context
import java.util.*
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
class PlaylistImpl
private constructor(
override val uid: Music.UID,
override val rawName: String,
override val sortName: SortName,
override val name: Name,
override val songs: List<Song>
) : Playlist {
override fun resolveName(context: Context) = rawName
override val rawSortName = null
override val durationMs = songs.sumOf { it.durationMs }
override val albums =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
@ -41,7 +38,7 @@ private constructor(
*
* @param songs The new [Song]s to use.
*/
fun edit(songs: List<Song>) = PlaylistImpl(uid, rawName, sortName, songs)
fun edit(songs: List<Song>) = PlaylistImpl(uid, name, songs)
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [edits].
@ -58,11 +55,10 @@ private constructor(
* @param songs The songs to initially populate the playlist with.
* @param musicSettings [MusicSettings] required for name configuration.
*/
fun new(name: String, songs: List<Song>, musicSettings: MusicSettings) =
fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) =
PlaylistImpl(
Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()),
name,
SortName(name, musicSettings),
Name.Known.from(name, null, musicSettings),
songs)
/**
@ -79,8 +75,7 @@ private constructor(
) =
PlaylistImpl(
rawPlaylist.playlistInfo.playlistUid,
rawPlaylist.playlistInfo.name,
SortName(rawPlaylist.playlistInfo.name, musicSettings),
Name.Known.from(rawPlaylist.playlistInfo.name, null, musicSettings),
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) })
}
}

View file

@ -106,7 +106,7 @@ private class UserLibraryImpl(
@Synchronized
override fun createPlaylist(name: String, songs: List<Song>) {
val playlistImpl = PlaylistImpl.new(name, songs, musicSettings)
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
playlistMap[playlistImpl.uid] = playlistImpl
}

View file

@ -85,7 +85,7 @@ class NavigationViewModel : ViewModel() {
logD("Already navigating, not doing explore action")
return
}
logD("Navigating to ${music.rawName}")
logD("Navigating to ${music.name}")
_exploreNavigationItem.put(music)
}
@ -118,7 +118,7 @@ class NavigationViewModel : ViewModel() {
if (artists.size == 1) {
exploreNavigateTo(artists[0])
} else {
logD("Navigating to a choice of ${artists.map { it.rawName }}")
logD("Navigating to a choice of ${artists.map { it.name }}")
_exploreArtistNavigationItem.put(item)
}
}

View file

@ -63,7 +63,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) :
is Genre -> binding.pickerImage.bind(music)
is Playlist -> binding.pickerImage.bind(music)
}
binding.pickerName.text = music.resolveName(binding.context)
binding.pickerName.text = music.name.resolve(binding.context)
}
companion object {
@ -81,7 +81,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) :
fun <T : Music> diffCallback() =
object : SimpleDiffCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T) =
oldItem.rawName == newItem.rawName
oldItem.name == newItem.name
}
}
}

View file

@ -124,7 +124,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
val context = requireContext()
val binding = requireBinding()
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
binding.playbackSong.text = song.name.resolve(context)
binding.playbackInfo.text = song.artists.resolveNames(context)
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
}

View file

@ -188,9 +188,9 @@ class PlaybackPanelFragment :
val binding = requireBinding()
val context = requireContext()
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
binding.playbackSong.text = song.name.resolve(context)
binding.playbackArtist.text = song.artists.resolveNames(context)
binding.playbackAlbum.text = song.album.resolveName(context)
binding.playbackAlbum.text = song.album.name.resolve(context)
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
}
@ -198,7 +198,7 @@ class PlaybackPanelFragment :
val binding = requireBinding()
val context = requireContext()
binding.playbackToolbar.subtitle =
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
parent?.run { name.resolve(context) } ?: context.getString(R.string.lbl_all_songs)
}
private fun updatePosition(positionDs: Long) {

View file

@ -150,7 +150,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
fun bind(song: Song, listener: EditableListListener<Song>) {
listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
// Not swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See QueueDragCallback for why this is done.

View file

@ -289,12 +289,12 @@ constructor(
// Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times.
val title = song.resolveName(context)
val title = song.name.resolve(context)
val artist = song.artists.resolveNames(context)
val builder =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context))
// Note: We would leave the artist field null if it didn't exist and let downstream
// consumers handle it, but that would break the notification display.
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
@ -305,14 +305,17 @@ constructor(
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(
// TODO: Remove in favor of METADATA_KEY_DISPLAY_DESCRIPTION
METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
parent?.run { name.resolve(context) }
?: context.getString(R.string.lbl_all_songs))
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
parent?.run { name.resolve(context) }
?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
// These fields are nullable and so we must check first before adding them to the fields.
song.track?.let {
@ -353,7 +356,7 @@ constructor(
// Media ID should not be the item index but rather the UID,
// as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString())
.setTitle(song.resolveName(context))
.setTitle(song.name.resolve(context))
.setSubtitle(song.artists.resolveNames(context))
// Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap.

View file

@ -227,7 +227,7 @@ class PlaybackService :
return
}
logD("Loading ${song.rawName}")
logD("Loading ${song.name}")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
player.playWhenReady = play

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
/**
* Implements the fuzzy-ish searching algorithm used in the search view.
@ -63,7 +64,11 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
SearchEngine {
override suspend fun search(items: SearchEngine.Items, query: String) =
SearchEngine.Items(
songs = items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q) },
songs =
items.songs?.searchListImpl(query) { q, song ->
// FIXME: Match case-insensitively
song.path.name.contains(q)
},
albums = items.albums?.searchListImpl(query),
artists = items.artists?.searchListImpl(query),
genres = items.genres?.searchListImpl(query))
@ -84,17 +89,21 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
filter {
// See if the plain resolved name matches the query. This works for most
// situations.
val name = it.resolveName(context)
if (name.contains(query, ignoreCase = true)) {
val name = it.name
val resolvedName = name.resolve(context)
if (resolvedName.contains(query, ignoreCase = true)) {
return@filter true
}
// See if the sort name matches. This can sometimes be helpful as certain
// libraries
// will tag sort names to have a alphabetized version of the title.
val sortName = it.rawSortName
if (sortName != null && sortName.contains(query, ignoreCase = true)) {
return@filter true
if (name is Name.Known) {
val sortName = name.sort
if (sortName != null && sortName.contains(query, ignoreCase = true)) {
return@filter true
}
}
// As a last-ditch effort, see if the normalized name matches. This will replace
@ -103,7 +112,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
// could make it match the query.
val normalizedName =
NORMALIZE_POST_PROCESSING_REGEX.replace(
Normalizer.normalize(name, Normalizer.Form.NFKD), "")
Normalizer.normalize(resolvedName, Normalizer.Form.NFKD), "")
if (normalizedName.contains(query, ignoreCase = true)) {
return@filter true
}

View file

@ -248,7 +248,8 @@ class WidgetProvider : AppWidgetProvider() {
setImageViewBitmap(R.id.widget_cover, state.cover)
setContentDescription(
R.id.widget_cover,
context.getString(R.string.desc_album_cover, state.song.album.resolveName(context)))
context.getString(
R.string.desc_album_cover, state.song.album.name.resolve(context)))
} else {
// We are unable to use the typical placeholder cover with the song item due to
// limitations with the corner radius. Instead use a custom-made album icon as the
@ -272,7 +273,7 @@ class WidgetProvider : AppWidgetProvider() {
state: WidgetComponent.PlaybackState
): RemoteViews {
setupCover(context, state)
setTextViewText(R.id.widget_song, state.song.resolveName(context))
setTextViewText(R.id.widget_song, state.song.name.resolve(context))
setTextViewText(R.id.widget_artist, state.song.artists.resolveNames(context))
return this
}

View file

@ -53,7 +53,7 @@
<string name="lbl_compilation">Compilation</string>
<!-- As in a compilation of live music -->
<string name="lbl_compilation_live">Live compilation</string>
<string name="lbl_compilation_remix">Remix compilations</string>
<string name="lbl_compilation_remix">Remix compilation</string>
<string name="lbl_soundtracks">Soundtracks</string>
<string name="lbl_soundtrack">Soundtrack</string>
<!-- As in the collection of music -->

View file

@ -22,9 +22,9 @@ import android.content.Context
import android.net.Uri
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.ReleaseType
open class FakeSong : Song {
override val rawName: String?

View file

@ -24,7 +24,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.info.Date
class DeviceMusicImplTest {
@Test

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.junit.Assert.assertEquals
import org.junit.Test