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:
parent
ca349dea18
commit
c7b875376c
51 changed files with 384 additions and 255 deletions
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -46,7 +46,6 @@ constructor(
|
|||
private val imageSettings: ImageSettings,
|
||||
private val mediaSourceFactory: MediaSource.Factory
|
||||
) {
|
||||
|
||||
suspend fun extract(album: Album): InputStream? =
|
||||
try {
|
||||
when (imageSettings.coverMode) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.*
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
217
app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
Normal file
217
app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
Normal 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+)")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in a new issue