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:
parent
82a64b5e17
commit
26f0fb7aba
12 changed files with 168 additions and 70 deletions
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>()
|
||||||
}
|
}
|
||||||
|
|
31
app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt
Normal file
31
app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt
Normal 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)
|
||||||
|
}
|
|
@ -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()) }
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
39
app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt
Normal file
39
app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue