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() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = album.resolveName(requireContext()) requireBinding().detailToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album) albumHeaderAdapter.setParent(album)
} }

View file

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

View file

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

View file

@ -187,7 +187,7 @@ class PlaylistDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = playlist.resolveName(requireContext()) requireBinding().detailToolbar.title = playlist.name.resolve(requireContext())
playlistHeaderAdapter.setParent(playlist) 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.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.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.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
@ -67,10 +68,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
binding.detailProperties.adapter = detailAdapter binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.songUid) 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) { if (song == null) {
// Song we were showing no longer exists. // Song we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
@ -123,12 +124,14 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
} }
} }
private fun <T : Music> T.zipName(context: Context) = private fun <T : Music> T.zipName(context: Context): String {
if (rawSortName != null) { val name = name
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName) return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else { } else {
resolveName(context) name.resolve(context)
} }
}
private fun <T : Music> List<T>.zipNames(context: Context) = private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(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.) // The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes) 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 // Artist name maps to the subhead text
binding.detailSubhead.apply { binding.detailSubhead.apply {

View file

@ -63,7 +63,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) { fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist) binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_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()) { if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text // 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) { fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre) binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_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. // Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text. // 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) { fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(playlist) binding.detailCover.bind(playlist)
binding.detailType.text = binding.context.getString(R.string.lbl_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. // Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false binding.detailSubhead.isVisible = false
// The song count of the playlist maps to the info text. // 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.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song 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.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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 // Use duration instead of album or artist for each song, as this text would
// be homogenous otherwise. // be homogenous otherwise.
@ -204,7 +204,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() { object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: 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>) { fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu) listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album) binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context) binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information // Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context) album.dates?.resolveDate(binding.context)
@ -139,7 +139,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() { object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: 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>) { fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.album.resolveName(binding.context) binding.songInfo.text = song.album.name.resolve(binding.context)
} }
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -191,8 +191,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() { object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) = override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.name == newItem.name && oldItem.album.name == newItem.album.name
oldItem.album.rawName == newItem.album.rawName
} }
} }
} }

View file

@ -94,10 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) { return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// By Name -> Use Name // 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 // 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) // 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()) } 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) { return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.sortName?.thumbString is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.sortName?.thumbString is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> playlist.sortName?.thumbString is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false) 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. // based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) { return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.sortName?.thumbString is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist // 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 // Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year // Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext()) 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) CoilUtils.dispose(this)
imageLoader.enqueue(request) imageLoader.enqueue(request)
// Update the content description to the specified resource. // 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 imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory private val mediaSourceFactory: MediaSource.Factory
) { ) {
suspend fun extract(album: Album): InputStream? = suspend fun extract(album: Album): InputStream? =
try { try {
when (imageSettings.coverMode) { 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. * @param song The [Song] to create the menu for.
*/ */
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) { 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) { openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) { when (it.itemId) {
@ -120,7 +120,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param album The [Album] to create the menu for. * @param album The [Album] to create the menu for.
*/ */
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) { 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) { openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) { when (it.itemId) {
@ -157,7 +157,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param artist The [Artist] to create the menu for. * @param artist The [Artist] to create the menu for.
*/ */
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) { 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) { openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) { when (it.itemId) {
@ -191,7 +191,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param genre The [Genre] to create the menu for. * @param genre The [Genre] to create the menu for.
*/ */
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) { 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) { openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) { when (it.itemId) {
@ -225,7 +225,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param playlist The [Playlist] to create the menu for. * @param playlist The [Playlist] to create the menu for.
*/ */
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) { 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) { openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) { when (it.itemId) {

View file

@ -24,8 +24,8 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.info.Disc
/** /**
* A sorting method. * A sorting method.
@ -566,16 +566,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Music.collationKey * @see Music.collationKey
*/ */
private class BasicComparator<T : Music> private constructor() : Comparator<T> { private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T): Int { override fun compare(a: T, b: T) = a.name.compareTo(b.name)
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
}
}
companion object { companion object {
/** A re-usable instance configured for [Song]s. */ /** 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>) { fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(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 = val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() { object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) = override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.name == newItem.name && oldItem.artists.areNamesTheSame(newItem.artists)
oldItem.artists.areRawNamesTheSame(newItem.artists)
} }
} }
} }
@ -102,7 +101,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
fun bind(album: Album, listener: SelectableListListener<Album>) { fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu) listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album) binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context) binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = album.artists.resolveNames(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 = val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() { object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) = override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.name == newItem.name &&
oldItem.artists.areRawNamesTheSame(newItem.artists) && oldItem.artists.areNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType oldItem.releaseType == newItem.releaseType
} }
} }
@ -154,7 +153,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
fun bind(artist: Artist, listener: SelectableListListener<Artist>) { fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
listener.bind(artist, this, menuButton = binding.parentMenu) listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist) binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context) binding.parentName.text = artist.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
binding.context.getString( binding.context.getString(
@ -193,7 +192,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Artist>() { object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName && oldItem.name == newItem.name &&
oldItem.albums.size == newItem.albums.size && oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.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>) { fun bind(genre: Genre, listener: SelectableListListener<Genre>) {
listener.bind(genre, this, menuButton = binding.parentMenu) listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre) binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context) binding.parentName.text = genre.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
@ -249,7 +248,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() { object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.rawName == newItem.rawName && oldItem.name == newItem.name &&
oldItem.artists.size == newItem.artists.size && oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.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>) { fun bind(playlist: Playlist, listener: SelectableListListener<Playlist>) {
listener.bind(playlist, this, menuButton = binding.parentMenu) listener.bind(playlist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(playlist) binding.parentImage.bind(playlist)
binding.parentName.text = playlist.resolveName(binding.context) binding.parentName.text = playlist.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) 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 = val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() { object : SimpleDiffCallback<Playlist>() {
override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean = 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.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.text.CollationKey
import java.text.Collator
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
@ -31,9 +29,10 @@ import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType 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.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.toUuidOrNull
@ -51,35 +50,8 @@ sealed interface Music : Item {
*/ */
val uid: UID val uid: UID
/** /** The [Name] of the music item. */
* The raw name of this item as it was extracted from the file-system. Will be null if the val name: Name
* 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?
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
@ -342,61 +314,6 @@ interface Playlist : MusicParent {
val durationMs: Long 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] * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
* in a localized manner. * in a localized manner.
@ -405,20 +322,20 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName
* @return A concatenated string. * @return A concatenated string.
*/ */
fun <T : Music> List<T>.resolveNames(context: Context) = 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 * Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* display information of an item must be compared without a context. * information of an item must be compared without a context.
* *
* @param other The list of items to compare to. * @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)) { for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false val b = other.getOrNull(i) ?: return false
if (a.rawName != b.rawName) { if (a.name != b.name) {
return false return false
} }
} }

View file

@ -28,7 +28,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong 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.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped

View file

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

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.music.device
import java.util.UUID import java.util.UUID
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.fs.Directory 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.* 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.MusicSettings
import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.cache.Cache
import org.oxycblt.auxio.music.device.RawSong 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.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.transformPositionField import org.oxycblt.auxio.music.metadata.transformPositionField
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat

View file

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

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.metadata package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
@ -26,8 +26,6 @@ import org.oxycblt.auxio.list.Item
* @param number The disc number. * @param number The disc number.
* @param name The name of the disc group, if any. Null if not present. * @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> { data 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
override fun compareTo(other: Disc) = number.compareTo(other.number) 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/>. * 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 import org.oxycblt.auxio.R

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * 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 * 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 * 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. * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class AudioInfo( data class AudioProperties(
val bitrateKbps: Int?, val bitrateKbps: Int?,
val sampleRateHz: Int?, val sampleRateHz: Int?,
val resolvedMimeType: MimeType 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 { interface Factory {
/** /**
* Extract the [AudioInfo] of a given [Song]. * Extract the [AudioProperties] of a given [Song].
* *
* @param song The [Song] to read. * @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. * @param context [Context] required to read audio files.
*/ */
class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) : class AudioPropertiesFactoryImpl
AudioInfo.Factory { @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 // While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no // common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
@ -76,7 +77,7 @@ class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c
// that we can show. // that we can show.
logW("Unable to extract song attributes.") logW("Unable to extract song attributes.")
logW(e.stackTraceToString()) logW(e.stackTraceToString())
return AudioInfo(null, null, song.mimeType) return AudioProperties(null, null, song.mimeType)
} }
// Get the first track from the extractor (This is basically always the only // Get the first track from the extractor (This is basically always the only
@ -122,6 +123,6 @@ class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c
extractor.release() 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 { interface MetadataModule {
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor @Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory @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 javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri 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.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW

View file

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

View file

@ -106,7 +106,7 @@ private class UserLibraryImpl(
@Synchronized @Synchronized
override fun createPlaylist(name: String, songs: List<Song>) { 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 playlistMap[playlistImpl.uid] = playlistImpl
} }

View file

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

View file

@ -63,7 +63,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) :
is Genre -> binding.pickerImage.bind(music) is Genre -> binding.pickerImage.bind(music)
is Playlist -> 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 { companion object {
@ -81,7 +81,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) :
fun <T : Music> diffCallback() = fun <T : Music> diffCallback() =
object : SimpleDiffCallback<T>() { object : SimpleDiffCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: 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 context = requireContext()
val binding = requireBinding() val binding = requireBinding()
binding.playbackCover.bind(song) 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.playbackInfo.text = song.artists.resolveNames(context)
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
} }

View file

@ -188,9 +188,9 @@ class PlaybackPanelFragment :
val binding = requireBinding() val binding = requireBinding()
val context = requireContext() val context = requireContext()
binding.playbackCover.bind(song) 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.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() binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
} }
@ -198,7 +198,7 @@ class PlaybackPanelFragment :
val binding = requireBinding() val binding = requireBinding()
val context = requireContext() val context = requireContext()
binding.playbackToolbar.subtitle = 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) { 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>) { fun bind(song: Song, listener: EditableListListener<Song>) {
listener.bind(song, this, bodyView, binding.songDragHandle) listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(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 swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See QueueDragCallback for why this is done. // 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 // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times. // several times.
val title = song.resolveName(context) val title = song.name.resolve(context)
val artist = song.artists.resolveNames(context) val artist = song.artists.resolveNames(context)
val builder = val builder =
MediaMetadataCompat.Builder() MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) .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 // 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. // consumers handle it, but that would break the notification display.
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
@ -305,14 +305,17 @@ constructor(
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText( .putText(
// TODO: Remove in favor of METADATA_KEY_DISPLAY_DESCRIPTION
METADATA_KEY_PARENT, 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_GENRE, song.genres.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText( .putText(
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, 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) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
// These fields are nullable and so we must check first before adding them to the fields. // These fields are nullable and so we must check first before adding them to the fields.
song.track?.let { song.track?.let {
@ -353,7 +356,7 @@ constructor(
// Media ID should not be the item index but rather the UID, // 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. // as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString()) .setMediaId(song.uid.toString())
.setTitle(song.resolveName(context)) .setTitle(song.name.resolve(context))
.setSubtitle(song.artists.resolveNames(context)) .setSubtitle(song.artists.resolveNames(context))
// Since we usually have to load many songs into the queue, use the // Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap. // MediaStore URI instead of loading a bitmap.

View file

@ -227,7 +227,7 @@ class PlaybackService :
return return
} }
logD("Loading ${song.rawName}") logD("Loading ${song.name}")
player.setMediaItem(MediaItem.fromUri(song.uri)) player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare() player.prepare()
player.playWhenReady = play 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.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
/** /**
* Implements the fuzzy-ish searching algorithm used in the search view. * Implements the fuzzy-ish searching algorithm used in the search view.
@ -63,7 +64,11 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
SearchEngine { SearchEngine {
override suspend fun search(items: SearchEngine.Items, query: String) = override suspend fun search(items: SearchEngine.Items, query: String) =
SearchEngine.Items( 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), albums = items.albums?.searchListImpl(query),
artists = items.artists?.searchListImpl(query), artists = items.artists?.searchListImpl(query),
genres = items.genres?.searchListImpl(query)) genres = items.genres?.searchListImpl(query))
@ -84,17 +89,21 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
filter { filter {
// See if the plain resolved name matches the query. This works for most // See if the plain resolved name matches the query. This works for most
// situations. // situations.
val name = it.resolveName(context) val name = it.name
if (name.contains(query, ignoreCase = true)) {
val resolvedName = name.resolve(context)
if (resolvedName.contains(query, ignoreCase = true)) {
return@filter true return@filter true
} }
// See if the sort name matches. This can sometimes be helpful as certain // See if the sort name matches. This can sometimes be helpful as certain
// libraries // libraries
// will tag sort names to have a alphabetized version of the title. // will tag sort names to have a alphabetized version of the title.
val sortName = it.rawSortName if (name is Name.Known) {
if (sortName != null && sortName.contains(query, ignoreCase = true)) { val sortName = name.sort
return@filter true 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 // 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. // could make it match the query.
val normalizedName = val normalizedName =
NORMALIZE_POST_PROCESSING_REGEX.replace( NORMALIZE_POST_PROCESSING_REGEX.replace(
Normalizer.normalize(name, Normalizer.Form.NFKD), "") Normalizer.normalize(resolvedName, Normalizer.Form.NFKD), "")
if (normalizedName.contains(query, ignoreCase = true)) { if (normalizedName.contains(query, ignoreCase = true)) {
return@filter true return@filter true
} }

View file

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

View file

@ -53,7 +53,7 @@
<string name="lbl_compilation">Compilation</string> <string name="lbl_compilation">Compilation</string>
<!-- As in a compilation of live music --> <!-- As in a compilation of live music -->
<string name="lbl_compilation_live">Live compilation</string> <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_soundtracks">Soundtracks</string>
<string name="lbl_soundtrack">Soundtrack</string> <string name="lbl_soundtrack">Soundtrack</string>
<!-- As in the collection of music --> <!-- As in the collection of music -->

View file

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

View file

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

View file

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

View file

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

View file

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