detail: add support for disc subtittles

Add support for disc subtitle information

This allows disc groups to become named, which is useful for certain
multi-part albums.

Resolves #331.
This commit is contained in:
Alexander Capehart 2023-01-21 17:22:15 -07:00
parent 82a64b5e17
commit 26f0fb7aba
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 168 additions and 70 deletions

View file

@ -2,10 +2,14 @@
## dev ## dev
#### What's New
- Added support for disc subtitles
#### What's Improved #### What's Improved
- Auxio will now accept zeroed track/disc numbers in the presence of non-zero total - Auxio will now accept zeroed track/disc numbers in the presence of non-zero total
track/disc fields. track/disc fields.
## 3.0.2 ## 3.0.2
#### What's New #### What's New

View file

@ -29,13 +29,6 @@ import org.oxycblt.auxio.music.storage.MimeType
*/ */
data class SortHeader(@StringRes val titleRes: Int) : Item data class SortHeader(@StringRes val titleRes: Int) : Item
/**
* A header variation that delimits between disc groups.
* @param disc The disc number to be displayed on the header.
* @author Alexander Capehart (OxygenCobalt)
*/
data class DiscHeader(val disc: Int) : Item
/** /**
* The properties of a [Song]'s file. * The properties of a [Song]'s file.
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.

View file

@ -37,6 +37,7 @@ import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.tags.Disc
import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
@ -323,11 +324,11 @@ class DetailViewModel(application: Application) :
// songs up by disc and then delimit the groups by a disc header. // songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs) val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1. // Songs without disc tags become part of Disc 1.
val byDisc = songs.groupBy { it.disc ?: 1 } val byDisc = songs.groupBy { it.disc ?: Disc(1, null) }
if (byDisc.size > 1) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers") logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
data.add(DiscHeader(entry.key)) data.add(entry.key)
data.addAll(entry.value) data.addAll(entry.value)
} }
} else { } else {

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
@ -26,13 +27,13 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter 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.tags.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.getPlural import org.oxycblt.auxio.util.getPlural
@ -60,7 +61,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
when (getItem(position)) { when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs. // Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE is Album -> AlbumDetailViewHolder.VIEW_TYPE
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -68,7 +69,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent) AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent) DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -77,7 +78,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
when (val item = getItem(position)) { when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) is Disc -> (holder as DiscViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener) is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
} }
} }
@ -88,7 +89,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
} }
// The album and disc headers should be full-width in all configurations. // The album and disc headers should be full-width in all configurations.
val item = getItem(position) val item = getItem(position)
return item is Album || item is DiscHeader return item is Album || item is Disc
} }
private companion object { private companion object {
@ -99,8 +100,8 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return when { return when {
oldItem is Album && newItem is Album -> oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscHeader && newItem is DiscHeader -> oldItem is Disc && newItem is Disc ->
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -182,18 +183,22 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
} }
/** /**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
* [from] to create an instance. * to create an instance.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* @param discHeader The new [DiscHeader] to bind. * @param disc The new [disc] to bind.
*/ */
fun bind(discHeader: DiscHeader) { fun bind(disc: Disc) {
binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.disc) binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
binding.discName.apply {
text = disc.name
isGone = disc.name == null
}
} }
companion object { companion object {
@ -206,13 +211,13 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
* @return A new instance. * @return A new instance.
*/ */
fun from(parent: View) = fun from(parent: View) =
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<DiscHeader>() { object : SimpleDiffCallback<Disc>() {
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = override fun areContentsTheSame(oldItem: Disc, newItem: Disc) =
oldItem.disc == newItem.disc oldItem.number == newItem.number && oldItem.name == newItem.name
} }
} }
} }

View file

@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.Disc
import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -340,8 +341,8 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
/** The track number. Will be null if no valid track number was present in the metadata. */ /** The track number. Will be null if no valid track number was present in the metadata. */
val track = raw.track val track = raw.track
/** The disc number. Will be null if no valid disc number was present in the metadata. */ /** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
val disc = raw.disc val disc = raw.disc?.let { Disc(it, raw.subtitle) }
/** The release [Date]. Will be null if no valid date was present in the metadata. */ /** The release [Date]. Will be null if no valid date was present in the metadata. */
val date = raw.date val date = raw.date
@ -573,8 +574,10 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
var sortName: String? = null, var sortName: String? = null,
/** @see Song.track */ /** @see Song.track */
var track: Int? = null, var track: Int? = null,
/** @see Song.disc */ /** @see Disc.number */
var disc: Int? = null, var disc: Int? = null,
/** @See Disc.name */
var subtitle: String? = null,
/** @see Song.date */ /** @see Song.date */
var date: Date? = null, var date: Date? = null,
/** @see Album.Raw.mediaStoreId */ /** @see Album.Raw.mediaStoreId */

View file

@ -186,6 +186,7 @@ private class CacheDatabase(context: Context) :
append("${Columns.SORT_NAME} STRING,") append("${Columns.SORT_NAME} STRING,")
append("${Columns.TRACK} INT,") append("${Columns.TRACK} INT,")
append("${Columns.DISC} INT,") append("${Columns.DISC} INT,")
append("${Columns.SUBTITLE} STRING,")
append("${Columns.DATE} STRING,") append("${Columns.DATE} STRING,")
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,") append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
append("${Columns.ALBUM_NAME} STRING NOT NULL,") append("${Columns.ALBUM_NAME} STRING NOT NULL,")
@ -243,6 +244,7 @@ private class CacheDatabase(context: Context) :
val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK) val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK)
val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC) val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC)
val subtitleIndex = cursor.getColumnIndex(Columns.SUBTITLE)
val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE) val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE)
val albumMusicBrainzIdIndex = val albumMusicBrainzIdIndex =
@ -281,6 +283,7 @@ private class CacheDatabase(context: Context) :
raw.track = cursor.getIntOrNull(trackIndex) raw.track = cursor.getIntOrNull(trackIndex)
raw.disc = cursor.getIntOrNull(discIndex) raw.disc = cursor.getIntOrNull(discIndex)
raw.subtitle = cursor.getStringOrNull(subtitleIndex)
raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from) raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex) raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
@ -346,6 +349,7 @@ private class CacheDatabase(context: Context) :
put(Columns.TRACK, rawSong.track) put(Columns.TRACK, rawSong.track)
put(Columns.DISC, rawSong.disc) put(Columns.DISC, rawSong.disc)
put(Columns.SUBTITLE, rawSong.subtitle)
put(Columns.DATE, rawSong.date?.toString()) put(Columns.DATE, rawSong.date?.toString())
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId) put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
@ -414,6 +418,8 @@ private class CacheDatabase(context: Context) :
const val TRACK = "track" const val TRACK = "track"
/** @see Song.Raw.disc */ /** @see Song.Raw.disc */
const val DISC = "disc" const val DISC = "disc"
/** @see Song.Raw.subtitle */
const val SUBTITLE = "subtitle"
/** @see Song.Raw.date */ /** @see Song.Raw.date */
const val DATE = "date" const val DATE = "date"
/** @see Song.Raw.albumMusicBrainzId */ /** @see Song.Raw.albumMusicBrainzId */
@ -442,7 +448,7 @@ private class CacheDatabase(context: Context) :
companion object { companion object {
private const val DB_NAME = "auxio_music_cache.db" private const val DB_NAME = "auxio_music_cache.db"
private const val DB_VERSION = 2 private const val DB_VERSION = 3
private const val TABLE_RAW_SONGS = "raw_songs" private const val TABLE_RAW_SONGS = "raw_songs"
@Volatile private var INSTANCE: CacheDatabase? = null @Volatile private var INSTANCE: CacheDatabase? = null

View file

@ -178,15 +178,16 @@ class Task(context: Context, private val raw: Song.Raw) {
*/ */
private fun populateWithId3v2(textFrames: Map<String, List<String>>) { private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song // Song
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] } textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { raw.name = it[0] } textFrames["TIT2"]?.let { raw.name = it.first() }
textFrames["TSOT"]?.let { raw.sortName = it[0] } textFrames["TSOT"]?.let { raw.sortName = it.first() }
// Track. Only parse out the track number and ignore the total tracks value. // Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { raw.track = it } textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { raw.track = it }
// Disc. Only parse out the disc number and ignore the total discs value. // Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it } textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it }
textFrames["TSST"]?.let { raw.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year // Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -204,9 +205,9 @@ class Task(context: Context, private val raw: Song.Raw) {
?.let { raw.date = it } ?.let { raw.date = it }
// Album // Album
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] } textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { raw.albumName = it[0] } textFrames["TALB"]?.let { raw.albumName = it.first() }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] } textFrames["TSOA"]?.let { raw.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.releaseTypes = it raw.releaseTypes = it
} }
@ -244,19 +245,19 @@ class Task(context: Context, private val raw: Song.Raw) {
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"] val tdat = textFrames["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) { return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
// TDAT frames consist of a 4-digit string where the first two digits are // TDAT frames consist of a 4-digit string where the first two digits are
// the month and the last two digits are the day. // the month and the last two digits are the day.
val mm = tdat[0].substring(0..1).toInt() val mm = tdat.first().substring(0..1).toInt()
val dd = tdat[0].substring(2..3).toInt() val dd = tdat.first().substring(2..3).toInt()
val time = textFrames["TIME"] val time = textFrames["TIME"]
if (time != null && time[0].length == 4 && time[0].isDigitsOnly()) { if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
// TIME frames consist of a 4-digit string where the first two digits are // TIME frames consist of a 4-digit string where the first two digits are
// the hour and the last two digits are the minutes. No second value is // the hour and the last two digits are the minutes. No second value is
// possible. // possible.
val hh = time[0].substring(0..1).toInt() val hh = time.first().substring(0..1).toInt()
val mi = time[0].substring(2..3).toInt() val mi = time.first().substring(2..3).toInt()
// Able to return a full date. // Able to return a full date.
Date.from(year, mm, dd, hh, mi) Date.from(year, mm, dd, hh, mi)
} else { } else {
@ -275,9 +276,9 @@ class Task(context: Context, private val raw: Song.Raw) {
*/ */
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // Song
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] } comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it.first() }
comments["title"]?.let { raw.name = it[0] } comments["title"]?.let { raw.name = it.first() }
comments["titlesort"]?.let { raw.sortName = it[0] } comments["titlesort"]?.let { raw.sortName = it.first() }
// Track. // Track.
parseVorbisPositionField( parseVorbisPositionField(
@ -285,11 +286,12 @@ class Task(context: Context, private val raw: Song.Raw) {
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { raw.track = it } ?.let { raw.track = it }
// Disc. // Disc and it's subtitle name.
parseVorbisPositionField( parseVorbisPositionField(
comments["discnumber"]?.first(), comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { raw.disc = it } ?.let { raw.disc = it }
comments["discsubtitle"]?.let { raw.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types // Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // Our hierarchy for dates is as such:
@ -303,9 +305,9 @@ class Task(context: Context, private val raw: Song.Raw) {
?.let { raw.date = it } ?.let { raw.date = it }
// Album // Album
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it.first() }
comments["album"]?.let { raw.albumName = it[0] } comments["album"]?.let { raw.albumName = it.first() }
comments["albumsort"]?.let { raw.albumSortName = it[0] } comments["albumsort"]?.let { raw.albumSortName = it.first() }
comments["releasetype"]?.let { raw.releaseTypes = it } comments["releasetype"]?.let { raw.releaseTypes = it }
// Artist // Artist

View file

@ -24,6 +24,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Sort.Mode import org.oxycblt.auxio.music.library.Sort.Mode
import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.Disc
/** /**
* A sorting method. * A sorting method.
@ -215,7 +216,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album }, compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -236,7 +237,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates }, compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
@ -263,7 +264,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates }, compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
@ -342,7 +343,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.INT) { it.disc }, compareByDynamic(isAscending, NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -360,7 +361,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator( MultiComparator(
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareByDynamic(isAscending, NullableComparator.INT) { it.track }, compareByDynamic(isAscending, NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -545,6 +546,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val INT = NullableComparator<Int>() val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */ /** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>() val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Disc]s */
val DISC = NullableComparator<Disc>()
/** A re-usable instance configured for [Date.Range]s. */ /** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator<Date.Range>() val DATE_RANGE = NullableComparator<Date.Range>()
} }

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.tags
import org.oxycblt.auxio.list.Item
/**
* A disc identifier for a song.
* @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
override fun compareTo(other: Disc) = number.compareTo(other.number)
}

View file

@ -300,7 +300,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
} }
song.disc?.let { song.disc?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong()) builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong())
} }
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) } song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -11,7 +11,7 @@
android:paddingBottom="@dimen/spacing_mid_medium"> android:paddingBottom="@dimen/spacing_mid_medium">
<org.oxycblt.auxio.image.StyledImageView <org.oxycblt.auxio.image.StyledImageView
android:id="@+id/disc_item" android:id="@+id/disc_icon"
style="@style/Widget.Auxio.Image.Small" style="@style/Widget.Auxio.Image.Small"
android:scaleType="matrix" android:scaleType="matrix"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
@ -21,19 +21,30 @@
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<TextView <TextView
android:id="@+id/disc_no" android:id="@+id/disc_number"
android:paddingStart="@dimen/spacing_medium" style="@style/Widget.Auxio.TextView.Item.Primary"
android:paddingEnd="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_mid_medium"
android:paddingBottom="@dimen/spacing_mid_medium"
android:textAppearance="@style/TextAppearance.Auxio.TitleMedium"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_marginEnd="@dimen/spacing_mid_medium"
android:textColor="@color/sel_selectable_text_primary"
app:layout_constraintBottom_toTopOf="@+id/disc_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/disc_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Disc 1" />
<TextView
android:id="@+id/disc_name"
style="@style/Widget.Auxio.TextView.Item.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/disc_item" tools:visibility="gone"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toEndOf="@+id/disc_icon"
tools:text="Disc 16" /> app:layout_constraintTop_toBottomOf="@+id/disc_number"
tools:text="Part 1" />
</LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.tags
import org.junit.Assert.assertTrue
import org.junit.Test
class DiscTest {
@Test
fun disc_equals_correct() {
val a = Disc(1, "Part I")
val b = Disc(1, "Part I")
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
@Test
fun disc_equals_inconsistentNames() {
val a = Disc(1, "Part I")
val b = Disc(1, null)
assertTrue(a == b)
assertTrue(a.hashCode() == b.hashCode())
}
}