music: add more failsafes to loading process
Add even more checks and guards to the music loading process to ensure proper metadata loading. Auxio has always had issues handling track numbers and years, mostly with ensuring their validity. This change resolves them by more or less surrounding them with gobs of null-checks and fallible parsing routines, which should help avoid frustrating bugs and crashes with metadata in the future. Resolves #88. Resolves #84.
This commit is contained in:
parent
dbe0bd1bb3
commit
1e39ceb9fc
13 changed files with 148 additions and 156 deletions
|
@ -11,12 +11,13 @@
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
- Fixed crash on certain devices running Android 10 and lower when a differing theme
|
- Fixed crash on certain devices running Android 10 and lower when a differing theme
|
||||||
from the system theme was used.
|
from the system theme was used [#80]
|
||||||
|
- Fixed music loading failure that would occur when certain paths were parsed [#84]
|
||||||
|
- Fixed incorrect track numbers when the tag was formatted as NN/TT [#88]
|
||||||
|
- Fixed years deliberately set as "0" showing up as "No Date"
|
||||||
|
|
||||||
#### What's Changed
|
#### What's Changed
|
||||||
- All cover art is now cropped to a 1:1 aspect ratio
|
- All cover art is now cropped to a 1:1 aspect ratio
|
||||||
- Song items no longer show an album in favor of a duration
|
|
||||||
- Album items no longer show a song count
|
|
||||||
|
|
||||||
#### Dev/Meta
|
#### Dev/Meta
|
||||||
- Enabled elevation drop shadows below Android P for consistency
|
- Enabled elevation drop shadows below Android P for consistency
|
||||||
|
|
|
@ -43,6 +43,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* TODO: Add a new view for crashes with a stack trace
|
* TODO: Add a new view for crashes with a stack trace
|
||||||
* TODO: Custom language support
|
* TODO: Custom language support
|
||||||
* TODO: Rework menus [perhaps add multi-select]
|
* TODO: Rework menus [perhaps add multi-select]
|
||||||
|
* TODO: Phase out databinding
|
||||||
*/
|
*/
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private val playbackModel: PlaybackViewModel by viewModels()
|
private val playbackModel: PlaybackViewModel by viewModels()
|
||||||
|
|
|
@ -107,16 +107,14 @@ abstract class BaseFetcher : Fetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
|
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
|
||||||
val extractor = MediaMetadataRetriever()
|
MediaMetadataRetriever().use { ext ->
|
||||||
|
|
||||||
extractor.use { ext ->
|
|
||||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
||||||
// so it's probably fine not to wrap it.
|
// so it's probably fine not to wrap it.
|
||||||
ext.setDataSource(context, album.songs[0].uri)
|
ext.setDataSource(context, album.songs[0].uri)
|
||||||
|
|
||||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||||
// ByteArray of the cover without any compression artifacts.
|
// ByteArray of the cover without any compression artifacts.
|
||||||
// If its null [a.k.a there is no embedded cover], than just ignore it and move on
|
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||||
return ext.embeddedPicture?.let { coverBytes ->
|
return ext.embeddedPicture?.let { coverBytes ->
|
||||||
ByteArrayInputStream(coverBytes)
|
ByteArrayInputStream(coverBytes)
|
||||||
}
|
}
|
||||||
|
@ -125,7 +123,6 @@ abstract class BaseFetcher : Fetcher {
|
||||||
|
|
||||||
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
|
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
|
||||||
val uri = album.songs[0].uri
|
val uri = album.songs[0].uri
|
||||||
|
|
||||||
val future = MetadataRetriever.retrieveMetadata(
|
val future = MetadataRetriever.retrieveMetadata(
|
||||||
context, MediaItem.fromUri(uri)
|
context, MediaItem.fromUri(uri)
|
||||||
)
|
)
|
||||||
|
@ -240,7 +237,8 @@ abstract class BaseFetcher : Fetcher {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the bitmap through a transform to make sure it's square
|
// Run the bitmap through a transform to make sure it's a square of the desired
|
||||||
|
// resolution.
|
||||||
val bitmap = SquareFrameTransform.INSTANCE
|
val bitmap = SquareFrameTransform.INSTANCE
|
||||||
.transform(
|
.transform(
|
||||||
BitmapFactory.decodeStream(stream),
|
BitmapFactory.decodeStream(stream),
|
||||||
|
|
|
@ -32,7 +32,6 @@ import org.oxycblt.auxio.music.ActionHeader
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Item
|
import org.oxycblt.auxio.music.Item
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.toDate
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
|
import org.oxycblt.auxio.ui.ActionHeaderViewHolder
|
||||||
import org.oxycblt.auxio.ui.BaseViewHolder
|
import org.oxycblt.auxio.ui.BaseViewHolder
|
||||||
|
@ -145,21 +144,19 @@ class AlbumDetailAdapter(
|
||||||
|
|
||||||
binding.detailSubhead.apply {
|
binding.detailSubhead.apply {
|
||||||
text = data.artist.resolvedName
|
text = data.artist.resolvedName
|
||||||
|
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
detailModel.navToItem(data.artist)
|
detailModel.navToItem(data.artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.detailInfo.text = binding.detailInfo.context.getString(
|
binding.detailInfo.apply {
|
||||||
R.string.fmt_three,
|
text = context.getString(
|
||||||
data.year.toDate(binding.detailInfo.context),
|
R.string.fmt_three,
|
||||||
binding.detailInfo.context.getPluralSafe(
|
data.year?.toString() ?: context.getString(R.string.def_date),
|
||||||
R.plurals.fmt_song_count,
|
context.getPluralSafe(R.plurals.fmt_song_count, data.songs.size),
|
||||||
data.songs.size
|
data.totalDuration
|
||||||
),
|
)
|
||||||
data.totalDuration
|
}
|
||||||
)
|
|
||||||
|
|
||||||
binding.detailPlayButton.setOnClickListener {
|
binding.detailPlayButton.setOnClickListener {
|
||||||
playbackModel.playAlbum(data, false)
|
playbackModel.playAlbum(data, false)
|
||||||
|
@ -180,7 +177,7 @@ class AlbumDetailAdapter(
|
||||||
|
|
||||||
// Hide the track number view if the track is zero, as generally a track number of
|
// Hide the track number view if the track is zero, as generally a track number of
|
||||||
// zero implies that the song does not have a track number.
|
// zero implies that the song does not have a track number.
|
||||||
val usePlaceholder = data.track < 1
|
val usePlaceholder = data.track == null
|
||||||
binding.songTrack.isInvisible = usePlaceholder
|
binding.songTrack.isInvisible = usePlaceholder
|
||||||
binding.songTrackPlaceholder.isInvisible = !usePlaceholder
|
binding.songTrackPlaceholder.isInvisible = !usePlaceholder
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,6 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeFragmentDirections
|
import org.oxycblt.auxio.home.HomeFragmentDirections
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.toDate
|
|
||||||
import org.oxycblt.auxio.ui.AlbumViewHolder
|
import org.oxycblt.auxio.ui.AlbumViewHolder
|
||||||
import org.oxycblt.auxio.ui.DisplayMode
|
import org.oxycblt.auxio.ui.DisplayMode
|
||||||
import org.oxycblt.auxio.ui.Sort
|
import org.oxycblt.auxio.ui.Sort
|
||||||
|
@ -79,7 +78,8 @@ class AlbumListFragment : HomeListFragment() {
|
||||||
.first().uppercase()
|
.first().uppercase()
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Year -> Use Full Year
|
||||||
is Sort.ByYear -> album.year.toDate(requireContext())
|
is Sort.ByYear -> album.year?.toString()
|
||||||
|
?: getString(R.string.def_date)
|
||||||
|
|
||||||
// Unsupported sort, error gracefully
|
// Unsupported sort, error gracefully
|
||||||
else -> ""
|
else -> ""
|
||||||
|
|
|
@ -25,7 +25,6 @@ import android.view.ViewGroup
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.toDate
|
|
||||||
import org.oxycblt.auxio.ui.DisplayMode
|
import org.oxycblt.auxio.ui.DisplayMode
|
||||||
import org.oxycblt.auxio.ui.SongViewHolder
|
import org.oxycblt.auxio.ui.SongViewHolder
|
||||||
import org.oxycblt.auxio.ui.Sort
|
import org.oxycblt.auxio.ui.Sort
|
||||||
|
@ -82,7 +81,8 @@ class SongListFragment : HomeListFragment() {
|
||||||
.first().uppercase()
|
.first().uppercase()
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Year -> Use Full Year
|
||||||
is Sort.ByYear -> song.album.year.toDate(requireContext())
|
is Sort.ByYear -> song.album.year?.toString()
|
||||||
|
?: getString(R.string.def_date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -64,30 +64,28 @@ data class Song(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
/** The file name of this song, excluding the full path. */
|
/** The file name of this song, excluding the full path. */
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
/** The parent directories of this song. More or less the complement to [fileName]. */
|
|
||||||
val dirs: String,
|
|
||||||
/** The total duration of this song, in millis. */
|
/** The total duration of this song, in millis. */
|
||||||
val duration: Long,
|
val duration: Long,
|
||||||
/** The track number of this song. */
|
/** The track number of this song, null if there isn't any. */
|
||||||
val track: Int,
|
val track: Int?,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
val internalMediaStoreId: Long,
|
val internalMediaStoreId: Long,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
|
val internalMediaStoreYear: Int?,
|
||||||
|
/** Internal field. Do not use. */
|
||||||
|
val internalMediaStoreAlbumName: String,
|
||||||
|
/** Internal field. Do not use. */
|
||||||
|
val internalMediaStoreAlbumId: Long,
|
||||||
|
/** Internal field. Do not use. */
|
||||||
val internalMediaStoreArtistName: String?,
|
val internalMediaStoreArtistName: String?,
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
val internalMediaStoreAlbumArtistName: String?,
|
val internalMediaStoreAlbumArtistName: String?,
|
||||||
/** Internal field. Do not use. */
|
|
||||||
val internalMediaStoreAlbumId: Long,
|
|
||||||
/** Internal field. Do not use. */
|
|
||||||
val internalMediaStoreAlbumName: String,
|
|
||||||
/** Internal field. Do not use. */
|
|
||||||
val internalMediaStoreYear: Int
|
|
||||||
) : Music() {
|
) : Music() {
|
||||||
override val id: Long get() {
|
override val id: Long get() {
|
||||||
var result = name.hashCode().toLong()
|
var result = name.hashCode().toLong()
|
||||||
result = 31 * result + album.name.hashCode()
|
result = 31 * result + album.name.hashCode()
|
||||||
result = 31 * result + album.artist.name.hashCode()
|
result = 31 * result + album.artist.name.hashCode()
|
||||||
result = 31 * result + track
|
result = 31 * result + (track ?: 0)
|
||||||
result = 31 * result + duration.hashCode()
|
result = 31 * result + duration.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -152,7 +150,7 @@ data class Song(
|
||||||
data class Album(
|
data class Album(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
/** The latest year of the songs in this album. */
|
/** The latest year of the songs in this album. */
|
||||||
val year: Int,
|
val year: Int?,
|
||||||
/** The URI for the cover art corresponding to this album. */
|
/** The URI for the cover art corresponding to this album. */
|
||||||
val albumCoverUri: Uri,
|
val albumCoverUri: Uri,
|
||||||
/** The songs of this album. */
|
/** The songs of this album. */
|
||||||
|
@ -169,7 +167,7 @@ data class Album(
|
||||||
override val id: Long get() {
|
override val id: Long get() {
|
||||||
var result = name.hashCode().toLong()
|
var result = name.hashCode().toLong()
|
||||||
result = 31 * result + artist.name.hashCode()
|
result = 31 * result + artist.name.hashCode()
|
||||||
result = 31 * result + year
|
result = 31 * result + (year ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.excluded.ExcludedDatabase
|
import org.oxycblt.auxio.excluded.ExcludedDatabase
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
@ -33,7 +35,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked
|
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked
|
||||||
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files?
|
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files?
|
||||||
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see
|
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see
|
||||||
* that their metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
|
* that the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
|
||||||
* DATE tag. Once again, this is because internally android uses an ancient in-house metadata
|
* DATE tag. Once again, this is because internally android uses an ancient in-house metadata
|
||||||
* parser to get everything indexed, and so far they have not bothered to modernize this parser
|
* parser to get everything indexed, and so far they have not bothered to modernize this parser
|
||||||
* or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has
|
* or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has
|
||||||
|
@ -65,7 +67,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
|
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
|
||||||
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
|
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
|
||||||
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
|
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
|
||||||
* to your AlgoPop StreamMix™ instead.
|
* to your AlgoPop StreamMix™.
|
||||||
*
|
*
|
||||||
* I wish I was born in the neolithic.
|
* I wish I was born in the neolithic.
|
||||||
*
|
*
|
||||||
|
@ -129,74 +131,80 @@ class MusicLoader {
|
||||||
args += "$path%" // Append % so that the selector properly detects children
|
args += "$path%" // Append % so that the selector properly detects children
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out the semantics of the track field to prevent odd 4-digit numbers
|
|
||||||
context.applicationContext.contentResolver.query(
|
context.applicationContext.contentResolver.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MediaStore.Audio.AudioColumns._ID,
|
MediaStore.Audio.AudioColumns._ID,
|
||||||
MediaStore.Audio.AudioColumns.TITLE,
|
MediaStore.Audio.AudioColumns.TITLE,
|
||||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||||
|
MediaStore.Audio.AudioColumns.DURATION,
|
||||||
|
MediaStore.Audio.AudioColumns.YEAR,
|
||||||
MediaStore.Audio.AudioColumns.ALBUM,
|
MediaStore.Audio.AudioColumns.ALBUM,
|
||||||
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
||||||
MediaStore.Audio.AudioColumns.ARTIST,
|
MediaStore.Audio.AudioColumns.ARTIST,
|
||||||
MediaStore.Audio.AudioColumns.ALBUM_ARTIST,
|
MediaStore.Audio.AudioColumns.ALBUM_ARTIST,
|
||||||
MediaStore.Audio.AudioColumns.YEAR,
|
|
||||||
MediaStore.Audio.AudioColumns.TRACK,
|
|
||||||
MediaStore.Audio.AudioColumns.DURATION,
|
|
||||||
MediaStore.Audio.AudioColumns.DATA
|
|
||||||
),
|
),
|
||||||
selector, args.toTypedArray(), null
|
selector, args.toTypedArray(), null
|
||||||
)?.use { cursor ->
|
)?.use { cursor ->
|
||||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||||
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||||
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||||
|
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
|
||||||
|
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||||
|
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||||
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||||
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||||
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||||
val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST)
|
val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST)
|
||||||
val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
|
||||||
val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
|
||||||
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
|
||||||
val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val id = cursor.getLong(idIndex)
|
val id = cursor.getLong(idIndex)
|
||||||
|
val title = cursor.getString(titleIndex)
|
||||||
val fileName = cursor.getString(fileIndex)
|
val fileName = cursor.getString(fileIndex)
|
||||||
val title = cursor.getString(titleIndex) ?: fileName
|
|
||||||
|
// CD_TRACK_NUMBER formats tracks as NN/TT or NN where N is the track number and
|
||||||
|
// T Is the total. Parse out the NN and ignore the rest. This has to be a highly
|
||||||
|
// redundant process, as there seems to be a weird amount of edge-cases.
|
||||||
|
val track = cursor.getStringOrNull(trackIndex)?.run {
|
||||||
|
split("/").getOrNull(0)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val duration = cursor.getLong(durationIndex)
|
||||||
|
val year = cursor.getIntOrNull(yearIndex)
|
||||||
|
|
||||||
val album = cursor.getString(albumIndex)
|
val album = cursor.getString(albumIndex)
|
||||||
val albumId = cursor.getLong(albumIdIndex)
|
val albumId = cursor.getLong(albumIdIndex)
|
||||||
|
|
||||||
// If the artist field is <unknown>, make it null. This makes handling the
|
// If the artist field is <unknown>, make it null. This makes handling the
|
||||||
// insanity of the artist field easier later on.
|
// insanity of the artist field easier later on.
|
||||||
val artist = cursor.getString(artistIndex).let {
|
val artist = cursor.getStringOrNull(artistIndex)?.run {
|
||||||
if (it != MediaStore.UNKNOWN_STRING) it else null
|
if (this == MediaStore.UNKNOWN_STRING) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val albumArtist = cursor.getStringOrNull(albumArtistIndex)
|
val albumArtist = cursor.getStringOrNull(albumArtistIndex)
|
||||||
|
|
||||||
val year = cursor.getInt(yearIndex)
|
// Note: Directory parsing is currently disabled until artist images are added.
|
||||||
val track = cursor.getInt(trackIndex)
|
// val dirs = cursor.getStringOrNull(dataIndex)?.run {
|
||||||
val duration = cursor.getLong(durationIndex)
|
// substringBeforeLast("/", "").ifEmpty { null }
|
||||||
|
// }
|
||||||
// More efficient to slice away DISPLAY_NAME from the full path then to
|
|
||||||
// grok the path components from DATA itself.
|
|
||||||
val dirs = cursor.getString(dataIndex).run {
|
|
||||||
substring(0 until lastIndexOfAny(listOf(fileName)))
|
|
||||||
}
|
|
||||||
|
|
||||||
songs.add(
|
songs.add(
|
||||||
Song(
|
Song(
|
||||||
title,
|
title,
|
||||||
fileName,
|
fileName,
|
||||||
dirs,
|
|
||||||
duration,
|
duration,
|
||||||
track,
|
track,
|
||||||
id,
|
id,
|
||||||
|
year,
|
||||||
|
album,
|
||||||
|
albumId,
|
||||||
artist,
|
artist,
|
||||||
albumArtist,
|
albumArtist,
|
||||||
albumId,
|
|
||||||
album,
|
|
||||||
year,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -233,7 +241,9 @@ class MusicLoader {
|
||||||
// Use the song with the latest year as our metadata song.
|
// Use the song with the latest year as our metadata song.
|
||||||
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
|
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
|
||||||
// weird years like "0" wont show up if there are alternatives.
|
// weird years like "0" wont show up if there are alternatives.
|
||||||
val templateSong = requireNotNull(albumSongs.maxByOrNull { it.internalMediaStoreYear })
|
val templateSong = requireNotNull(
|
||||||
|
albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 }
|
||||||
|
)
|
||||||
val albumName = templateSong.internalMediaStoreAlbumName
|
val albumName = templateSong.internalMediaStoreAlbumName
|
||||||
val albumYear = templateSong.internalMediaStoreYear
|
val albumYear = templateSong.internalMediaStoreYear
|
||||||
val albumCoverUri = ContentUris.withAppendedId(
|
val albumCoverUri = ContentUris.withAppendedId(
|
||||||
|
@ -325,7 +335,7 @@ class MusicLoader {
|
||||||
// so we skip genres that have them.
|
// so we skip genres that have them.
|
||||||
val id = cursor.getLong(idIndex)
|
val id = cursor.getLong(idIndex)
|
||||||
val name = cursor.getStringOrNull(nameIndex) ?: continue
|
val name = cursor.getStringOrNull(nameIndex) ?: continue
|
||||||
val resolvedName = name.getGenreNameCompat() ?: name
|
val resolvedName = name.genreNameCompat ?: name
|
||||||
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
|
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
|
||||||
|
|
||||||
genres.add(
|
genres.add(
|
||||||
|
@ -371,7 +381,6 @@ class MusicLoader {
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val id = cursor.getLong(idIndex)
|
val id = cursor.getLong(idIndex)
|
||||||
|
|
||||||
songs.find { it.internalMediaStoreId == id }?.let { song ->
|
songs.find { it.internalMediaStoreId == id }?.let { song ->
|
||||||
genreSongs.add(song)
|
genreSongs.add(song)
|
||||||
}
|
}
|
||||||
|
@ -382,4 +391,68 @@ class MusicLoader {
|
||||||
// If that is the case, we drop them.
|
// If that is the case, we drop them.
|
||||||
return genreSongs.ifEmpty { null }
|
return genreSongs.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val String.genreNameCompat: String? get() {
|
||||||
|
if (isDigitsOnly()) {
|
||||||
|
// ID3v1, just parse as an integer
|
||||||
|
return legacyGenreTable.getOrNull(toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsWith('(') && endsWith(')')) {
|
||||||
|
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
|
||||||
|
// Any genres formatted as "(CHARS)" will be ignored.
|
||||||
|
val genreInt = substring(1 until lastIndex).toIntOrNull()
|
||||||
|
if (genreInt != null) {
|
||||||
|
return legacyGenreTable.getOrNull(genreInt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current name is fine.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
|
||||||
|
* winamp extensions.
|
||||||
|
*/
|
||||||
|
private val legacyGenreTable = arrayOf(
|
||||||
|
// ID3 Standard
|
||||||
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
|
||||||
|
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
|
||||||
|
"Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack",
|
||||||
|
"Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance",
|
||||||
|
"Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
|
||||||
|
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop",
|
||||||
|
"Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic",
|
||||||
|
"Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta",
|
||||||
|
"Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret",
|
||||||
|
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
||||||
|
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
|
||||||
|
|
||||||
|
// Winamp Extensions
|
||||||
|
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin",
|
||||||
|
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
|
||||||
|
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
|
||||||
|
"Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music",
|
||||||
|
"Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam",
|
||||||
|
"Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
|
||||||
|
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall",
|
||||||
|
"Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", "Britpop",
|
||||||
|
"Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
|
||||||
|
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||||
|
"Thrash Metal", "Anime", "JPop", "Synthpop",
|
||||||
|
|
||||||
|
// Winamp 5.6+ extensions, used by EasyTAG and friends
|
||||||
|
// The only reason I include this set is because post-rock is a based genre and
|
||||||
|
// deserves a slot.
|
||||||
|
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
|
||||||
|
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
|
||||||
|
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
|
||||||
|
"Leftfield", "Lounge", "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk",
|
||||||
|
"Post-Rock", "Psytrance", "Shoegaze", "Space Rock", "Trop Rock", "World Music",
|
||||||
|
"Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
|
||||||
|
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
@ -99,14 +98,15 @@ class MusicStore private constructor() {
|
||||||
* @return The corresponding [Song] for this [uri], null if there isn't one.
|
* @return The corresponding [Song] for this [uri], null if there isn't one.
|
||||||
*/
|
*/
|
||||||
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
|
fun findSongForUri(uri: Uri, resolver: ContentResolver): Song? {
|
||||||
val cur = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
resolver.query(
|
||||||
|
uri,
|
||||||
cur?.use { cursor ->
|
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||||
|
null, null, null
|
||||||
|
)?.use { cursor ->
|
||||||
cursor.moveToFirst()
|
cursor.moveToFirst()
|
||||||
|
val fileName = cursor.getString(
|
||||||
// Make studio shut up about "invalid ranges" that don't exist
|
cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
||||||
@SuppressLint("Range")
|
)
|
||||||
val fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
|
||||||
|
|
||||||
return songs.find { it.fileName == fileName }
|
return songs.find { it.fileName == fileName }
|
||||||
}
|
}
|
||||||
|
@ -146,11 +146,9 @@ class MusicStore private constructor() {
|
||||||
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
val response = MusicStore().load(context)
|
val response = MusicStore().load(context)
|
||||||
|
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
RESPONSE = response
|
RESPONSE = response
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,81 +18,16 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import androidx.databinding.BindingAdapter
|
import androidx.databinding.BindingAdapter
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.util.getPluralSafe
|
import org.oxycblt.auxio.util.getPluralSafe
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
/**
|
|
||||||
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
|
|
||||||
* winamp extensions.
|
|
||||||
*/
|
|
||||||
private val ID3_GENRES = arrayOf(
|
|
||||||
// ID3 Standard
|
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz",
|
|
||||||
"Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno",
|
|
||||||
"Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno",
|
|
||||||
"Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental",
|
|
||||||
"Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk",
|
|
||||||
"Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
|
|
||||||
"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy",
|
|
||||||
"Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American",
|
|
||||||
"Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
|
||||||
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
|
|
||||||
|
|
||||||
// Winamp Extensions
|
|
||||||
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival",
|
|
||||||
"Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock",
|
|
||||||
"Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
|
|
||||||
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus",
|
|
||||||
"Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad",
|
|
||||||
"Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella",
|
|
||||||
"Euro-House", "Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie",
|
|
||||||
"Britpop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta", "Heavy Metal", "Black Metal",
|
|
||||||
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa", "Thrash Metal",
|
|
||||||
"Anime", "JPop", "Synthpop",
|
|
||||||
|
|
||||||
// Winamp 5.6+ extensions, used by EasyTAG and friends
|
|
||||||
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Downtempo",
|
|
||||||
"Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental", "Garage", "Global",
|
|
||||||
"IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock", // S I X T Y F I V E
|
|
||||||
"New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", "Shoegaze", "Space Rock",
|
|
||||||
"Trop Rock", "World Music", "Neoclassical", "Audiobook", "Audio Theatre", "Neue Deutsche Welle",
|
|
||||||
"Podcast", "Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- EXTENSION FUNCTIONS ---
|
// --- EXTENSION FUNCTIONS ---
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert legacy int-based ID3 genres to their human-readable genre
|
|
||||||
* @return The named genre for this legacy genre, null if there is no need to parse it
|
|
||||||
* or if the genre is invalid.
|
|
||||||
*/
|
|
||||||
fun String.getGenreNameCompat(): String? {
|
|
||||||
if (isDigitsOnly()) {
|
|
||||||
// ID3v1, just parse as an integer
|
|
||||||
return ID3_GENRES.getOrNull(toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith('(') && endsWith(')')) {
|
|
||||||
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
|
|
||||||
// Any genres formatted as "(CHARS)" will be ignored.
|
|
||||||
val genreInt = substring(1 until lastIndex).toIntOrNull()
|
|
||||||
|
|
||||||
if (genreInt != null) {
|
|
||||||
return ID3_GENRES.getOrNull(genreInt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current name is fine.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a [Long] of seconds into a string duration.
|
* Convert a [Long] of seconds into a string duration.
|
||||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then
|
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then
|
||||||
|
@ -114,14 +49,6 @@ fun Long.toDuration(isElapsed: Boolean): String {
|
||||||
return durationString
|
return durationString
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Int.toDate(context: Context): String {
|
|
||||||
return if (this == 0) {
|
|
||||||
context.getString(R.string.def_date)
|
|
||||||
} else {
|
|
||||||
toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BINDING ADAPTERS ---
|
// --- BINDING ADAPTERS ---
|
||||||
|
|
||||||
@BindingAdapter("songInfo")
|
@BindingAdapter("songInfo")
|
||||||
|
|
|
@ -39,6 +39,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* A [Fragment] that displays more information about the song, along with more media controls.
|
* A [Fragment] that displays more information about the song, along with more media controls.
|
||||||
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
|
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
|
* TODO: Handle RTL correctly in the playback buttons
|
||||||
*/
|
*/
|
||||||
class PlaybackFragment : Fragment() {
|
class PlaybackFragment : Fragment() {
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
|
|
@ -530,9 +530,7 @@ class PlaybackStateManager private constructor() {
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
start = System.currentTimeMillis()
|
start = System.currentTimeMillis()
|
||||||
|
|
||||||
val database = PlaybackStateDatabase.getInstance(context)
|
val database = PlaybackStateDatabase.getInstance(context)
|
||||||
|
|
||||||
playbackState = database.readState(musicStore)
|
playbackState = database.readState(musicStore)
|
||||||
queue = database.readQueue(musicStore)
|
queue = database.readQueue(musicStore)
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,7 +102,7 @@ sealed class Sort(open val isAscending: Boolean) {
|
||||||
is ByName -> songs.stringSort { it.name }
|
is ByName -> songs.stringSort { it.name }
|
||||||
|
|
||||||
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
|
else -> sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
|
||||||
album.songs.intSort(true) { it.track }
|
album.songs.intSort(true) { it.track ?: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ sealed class Sort(open val isAscending: Boolean) {
|
||||||
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
|
is ByArtist -> sortParents(albums.groupBy { it.artist }.keys)
|
||||||
.flatMap { ByYear(false).sortAlbums(it.albums) }
|
.flatMap { ByYear(false).sortAlbums(it.albums) }
|
||||||
|
|
||||||
is ByYear -> albums.intSort { it.year }
|
is ByYear -> albums.intSort { it.year ?: 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,7 +139,7 @@ sealed class Sort(open val isAscending: Boolean) {
|
||||||
* @see sortSongs
|
* @see sortSongs
|
||||||
*/
|
*/
|
||||||
fun sortAlbum(album: Album): List<Song> {
|
fun sortAlbum(album: Album): List<Song> {
|
||||||
return album.songs.intSort { it.track }
|
return album.songs.intSort { it.track ?: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue