music: introduce backend system

Move out the MediaStoreCompat interface into a full interface called
Backend.

In preparation for direct metadata parsing, it would be useful to
create some kind of object system to properly handle the capabilities
of each metadata indexing mode. Backend fulfills that by allowing
each object to implement their own query and then loading routine.

This system is designed somewhat strangely. This is firstly because
the ExoPlayer metadata backend will have to plug in to the original
MediaStore backend, so making methods more granular allows the
ExoPlayer backend to avoid some of the stupid inefficiencies from
the actual MediaStore backend, such as the genre loading process.
We also want to separate the steps of loading music in order to
more adequately show the current loading process to the user in
a future change.
This commit is contained in:
OxygenCobalt 2022-05-28 13:13:19 -06:00
parent c6d7d8fe39
commit 2576fb26ba
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
19 changed files with 589 additions and 552 deletions

View file

@ -30,7 +30,7 @@
- Made the layout of album songs more similar to other songs
#### Dev/Meta
- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian]
- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian, enricocid -> Italian]
- Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table
- Introduced new RecyclerView framework

View file

@ -53,7 +53,7 @@ object IntegerTable {
const val ITEM_TYPE_QUEUE_SONG = 0xA00E
/** "Music playback" Notification code */
const val NOTIFICATION_CODE = 0xA0A0
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** Intent request code */
const val REQUEST_CODE = 0xA0C0

View file

@ -1,523 +0,0 @@
/*
* Copyright (c) 2022 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
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD
/**
* This class acts as the base for most the black magic required to get a remotely sensible music
* indexing system while still optimizing for time. I would recommend you leave this module now
* before you lose your sanity trying to understand the hoops I had to jump through for this system,
* but if you really want to stay, here's a debrief on why this code is so awful.
*
* MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
* against humanity and probably a way to summon Zalgo if you look at it the wrong way.
*
* You think that if you wanted to query a song's genre from a media database, you could just put
* "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
* straightforward for this contract that was dropped on it's head as a baby. So instead, you have
* to query for each genre, query all the songs in each genre, and then iterate through those songs
* to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
* algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
* point have the devs considered that this system is absolutely insane, and instead focused on
* adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
* Music, and of course every Google Play Music user knew how great that turned out!
*
* 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? I
* sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see 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 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 been around for *21
* years.* *It can drink now.* All of my what.
*
* Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
* table, so we have to go for the less efficient "make a big query on all the songs lol" method so
* that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
* or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
* normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
* a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
* problems since I have to manage databases across version boundaries. Sometimes music will have a
* deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
* sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
* JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
*
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
* listening is. As a result, some players like Vanilla and VLC just hack their own
* pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
* Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
* harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
* even if I set aside those crippling issues and changed my indexer to that, it would face the even
* larger problem of how google keeps trying to kill the filesystem and force you into their
* ContentResolver API. In the future MediaStore could be the only system we have, which is also the
* day that greenland melts and birthdays stop happening forever.
*
* 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.
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
* to your AlgoPop StreamMix.
*
* I wish I was born in the neolithic.
*
* @author OxygenCobalt
*/
object Indexer {
/**
* The album_artist MediaStore field has existed since at least API 21, but until API 30 it was
* a proprietary extension for Google Play Music and was not documented. Since this field
* probably works on all versions Auxio supports, we suppress the warning about using a
* possibly-unsupported constant.
*/
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
fun index(context: Context): MusicStore.Library? {
// Establish the compatibility object to use when loading songs.
val compat =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Api30MediaStoreCompat()
} else {
Api21MediaStoreCompat()
}
val songs = loadSongs(context, compat)
if (songs.isEmpty()) return null
val albums = buildAlbums(songs)
val artists = buildArtists(albums)
val genres = readGenres(context, songs)
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
throw IllegalStateException(
"Found malformed song: ${song.rawName} [" +
"album: ${!song._isMissingAlbum} " +
"artist: ${!song._isMissingArtist} " +
"genre: ${!song._isMissingGenre}]")
}
}
return MusicStore.Library(genres, artists, albums, songs)
}
/**
* Does the initial query over the song database, including excluded directory checks. The songs
* returned by this function are **not** well-formed. The companion [buildAlbums],
* [buildArtists], and [readGenres] functions must be called with the returned list so that all
* songs are properly linked up.
*/
private fun loadSongs(context: Context, compat: MediaStoreCompat): List<Song> {
val excludedDatabase = ExcludedDatabase.getInstance(context)
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
val args = mutableListOf<String>()
// Apply the excluded directories by filtering out specific DATA values.
// DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
// so it's probably okay to use it. The only reason we would want to use
// another method is for external partitions support, but there is no demand for that.
for (path in excludedDatabase.readPaths()) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
var songs = mutableListOf<Song>()
// Establish the columns that work across all versions of android.
val proj =
mutableListOf(
MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST,
MediaStore.Audio.AudioColumns.DATA)
// Get the compat impl to add their version-specific columns.
compat.mutateAudioProjection(proj)
context.contentResolverSafe
.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
proj.toTypedArray(),
selector,
args.toTypedArray(),
null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
val fileIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
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 albumIdIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
while (cursor.moveToNext()) {
val raw = Audio()
raw.id = cursor.getLong(idIndex)
raw.title = cursor.getString(titleIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system. Once again though, OEM issues get in our way and
// this field isn't available on some platforms. In that case, see if we can
// grok a file name from the DATA field.
raw.displayName =
cursor.getStringOrNull(fileIndex)
?: cursor
.getStringOrNull(dataIndex)
?.substringAfterLast('/', MediaStore.UNKNOWN_STRING)
?: MediaStore.UNKNOWN_STRING
raw.duration = cursor.getLong(durationIndex)
raw.year = cursor.getIntOrNull(yearIndex)
raw.album = cursor.getStringOrNull(albumIndex)
raw.albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on.
raw.artist =
cursor.getStringOrNull(artistIndex)?.run {
if (this != MediaStore.UNKNOWN_STRING) {
this
} else {
null
}
}
raw.albumArtist = cursor.getStringOrNull(albumArtistIndex)
// Allow the compatibility object to add their fields
compat.populateAudio(cursor, raw)
songs.add(raw.toSong())
}
}
// Deduplicate songs to prevent (most) deformed music clones
songs =
songs
.distinctBy {
it.rawName to
it._mediaStoreAlbumName to
it._mediaStoreArtistName to
it._mediaStoreAlbumArtistName to
it.track to
it.disc to
it.durationMs
}
.toMutableList()
logD("Successfully loaded ${songs.size} songs")
return songs
}
/**
* Group songs up into their respective albums. Instead of using the unreliable album or artist
* databases, we instead group up songs by their *lowercase* artist and album name to create
* albums. This serves two purposes:
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
* makes sure both of those are resolved into a single artist called "Rammstein"
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
* that all songs are unified under a single album.
*
* This does come with some costs, it's far slower than using the album ID itself, and it may
* result in an unrelated album art being selected depending on the song chosen as the template,
* but it seems to work pretty well.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { it._albumGroupingId }
for (entry in songsByAlbum) {
val albumSongs = entry.value
// 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
// weird years like "0" wont show up if there are alternatives.
// Note: Normally we could want to use something like maxByWith, but apparently
// that does not exist in the kotlin stdlib yet.
val comparator = Sort.NullableComparator<Int>()
var templateSong = albumSongs[0]
for (i in 1..albumSongs.lastIndex) {
val candidate = albumSongs[i]
if (comparator.compare(templateSong.track, candidate.track) < 0) {
templateSong = candidate
}
}
val albumName = templateSong._mediaStoreAlbumName
val albumYear = templateSong._mediaStoreYear
val albumCoverUri =
ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
templateSong._mediaStoreAlbumId)
val artistName = templateSong._artistGroupingName
albums.add(
Album(
albumName,
albumYear,
albumCoverUri,
albumSongs,
artistName,
))
}
logD("Successfully built ${albums.size} albums")
return albums
}
/**
* Group up albums into artists. This also requires a de-duplication step due to some edge cases
* where [buildAlbums] could not detect duplicates.
*/
private fun buildArtists(albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._artistGroupingId }
for (entry in albumsByArtist) {
val templateAlbum = entry.value[0]
val artistName =
if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) {
templateAlbum._artistGroupingName
} else {
null
}
val artistAlbums = entry.value
artists.add(Artist(artistName, artistAlbums))
}
logD("Successfully built ${artists.size} artists")
return artists
}
/**
* Read all genres and link them up to the given songs. This is the code that requires me to
* make dozens of useless queries just to link genres up.
*/
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
context.contentResolverSafe
.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME),
null,
null,
null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
while (cursor.moveToNext()) {
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names
// are resolved as usual, but null values don't make sense and are often junk
// anyway, so we skip genres that have them.
val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue
val genreSongs = queryGenreSongs(context, id, songs) ?: continue
genres.add(Genre(name, genreSongs))
}
}
val songsWithoutGenres = songs.filter { it._isMissingGenre }
if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre.
val unknownGenre = Genre(null, songsWithoutGenres)
genres.add(unknownGenre)
}
logD("Successfully loaded ${genres.size} genres")
return genres
}
/**
* Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for
* some reason, so if that's the case then this function will return null.
*/
private fun queryGenreSongs(context: Context, genreId: Long, songs: List<Song>): List<Song>? {
val genreSongs = mutableListOf<Song>()
// Don't even bother blacklisting here as useless iterations are less expensive than IO
context.contentResolverSafe
.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null,
null,
null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
songs.find { it._mediaStoreId == id }?.let { song -> genreSongs.add(song) }
}
}
return genreSongs.ifEmpty { null }
}
/**
* Represents a song as it is represented by MediaStore. This is progressively mutated over
* several steps of the music loading process until it is complete enough to be transformed into
* a song.
*
* TODO: Add manual metadata parsing.
*/
private data class Audio(
var id: Long? = null,
var title: String? = null,
var displayName: String? = null,
var duration: Long? = null,
var track: Int? = null,
var disc: Int? = null,
var year: Int? = null,
var album: String? = null,
var albumId: Long? = null,
var artist: String? = null,
var albumArtist: String? = null,
) {
fun toSong(): Song =
Song(
requireNotNull(title) { "Malformed song: No title" },
requireNotNull(displayName) { "Malformed song: No file name" },
requireNotNull(duration) { "Malformed song: No duration" },
track,
disc,
requireNotNull(id) { "Malformed song: No song id" },
year,
requireNotNull(album) { "Malformed song: No album name" },
requireNotNull(albumId) { "Malformed song: No album id" },
artist,
albumArtist,
)
}
/** A compatibility interface to implement version-specific audio database querying. */
private interface MediaStoreCompat {
/** Mutate the pre-existing projection with version-specific values. */
fun mutateAudioProjection(proj: MutableList<String>)
/** Mutate [audio] with the columns added in [mutateAudioProjection], */
fun populateAudio(cursor: Cursor, audio: Audio)
}
@RequiresApi(Build.VERSION_CODES.R)
private class Api30MediaStoreCompat : MediaStoreCompat {
private var trackIndex: Int = -1
private var discIndex: Int = -1
override fun mutateAudioProjection(proj: MutableList<String>) {
proj.add(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
proj.add(MediaStore.Audio.AudioColumns.DISC_NUMBER)
}
override fun populateAudio(cursor: Cursor, audio: Audio) {
if (trackIndex == -1) {
trackIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
}
if (discIndex == -1) {
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
}
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while leaving out the
// total, as we have no use for it.
cursor
.getStringOrNull(trackIndex)
?.split('/', limit = 2)
?.getOrNull(0)
?.toIntOrNull()
?.let { audio.track = it }
cursor
.getStringOrNull(discIndex)
?.split('/', limit = 2)
?.getOrNull(0)
?.toIntOrNull()
?.let { audio.disc = it }
}
}
private class Api21MediaStoreCompat : MediaStoreCompat {
private var trackIndex: Int = -1
override fun mutateAudioProjection(proj: MutableList<String>) {
proj.add(MediaStore.Audio.AudioColumns.TRACK)
}
override fun populateAudio(cursor: Cursor, audio: Audio) {
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
}
// TRACK is formatted as DTTT where D is the disc number and T is the track number.
// At least, I think so. I've so far been unable to reproduce disc numbers on older
// devices. Keep it around just in case.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
audio.track = rawTrack % 1000
// A disc number of 0 means that there is no disc.
val disc = rawTrack / 1000
if (disc > 0) {
audio.disc = disc
}
}
}
}
}

View file

@ -81,6 +81,8 @@ data class Song(
val _mediaStoreArtistName: String?,
/** Internal field. Do not use. */
val _mediaStoreAlbumArtistName: String?,
/** Internal field. Do not use. */
val _mediaStoreGenreName: String?
) : Music() {
override val id: Long
get() {

View file

@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import java.lang.Exception
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.indexer.Indexer
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE

View file

@ -0,0 +1,196 @@
/*
* Copyright (c) 2022 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.indexer
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
object Indexer {
fun index(context: Context): MusicStore.Library? {
// Establish the backend to use when initially loading songs.
val backend =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
else -> Api21MediaStoreBackend()
}
val songs = buildSongs(context, backend)
if (songs.isEmpty()) return null
val albums = buildAlbums(songs)
val artists = buildArtists(albums)
val genres = buildGenres(songs)
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
throw IllegalStateException(
"Found malformed song: ${song.rawName} [" +
"album: ${!song._isMissingAlbum} " +
"artist: ${!song._isMissingArtist} " +
"genre: ${!song._isMissingGenre}]")
}
}
return MusicStore.Library(genres, artists, albums, songs)
}
/**
* Does the initial query over the song database, including excluded directory checks. The songs
* returned by this function are **not** well-formed. The companion [buildAlbums],
* [buildArtists], and [buildGenres] functions must be called with the returned list so that all
* songs are properly linked up.
*/
private fun buildSongs(context: Context, backend: Backend): List<Song> {
var songs = backend.query(context).use { cursor -> backend.loadSongs(context, cursor) }
// Deduplicate songs to prevent (most) deformed music clones
songs =
songs.distinctBy {
it.rawName to
it._mediaStoreAlbumName to
it._mediaStoreArtistName to
it._mediaStoreAlbumArtistName to
it._mediaStoreGenreName to
it.track to
it.disc to
it.durationMs
}
logD("Successfully loaded ${songs.size} songs")
return songs
}
/**
* Group songs up into their respective albums. Instead of using the unreliable album or artist
* databases, we instead group up songs by their *lowercase* artist and album name to create
* albums. This serves two purposes:
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
* makes sure both of those are resolved into a single artist called "Rammstein"
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
* that all songs are unified under a single album.
*
* This does come with some costs, it's far slower than using the album ID itself, and it may
* result in an unrelated album art being selected depending on the song chosen as the template,
* but it seems to work pretty well.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { it._albumGroupingId }
for (entry in songsByAlbum) {
val albumSongs = entry.value
// 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
// weird years like "0" wont show up if there are alternatives.
// Note: Normally we could want to use something like maxByWith, but apparently
// that does not exist in the kotlin stdlib yet.
val comparator = Sort.NullableComparator<Int>()
var templateSong = albumSongs[0]
for (i in 1..albumSongs.lastIndex) {
val candidate = albumSongs[i]
if (comparator.compare(templateSong.track, candidate.track) < 0) {
templateSong = candidate
}
}
val albumName = templateSong._mediaStoreAlbumName
val albumYear = templateSong._mediaStoreYear
val albumCoverUri =
ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
templateSong._mediaStoreAlbumId)
val artistName = templateSong._artistGroupingName
albums.add(
Album(
albumName,
albumYear,
albumCoverUri,
albumSongs,
artistName,
))
}
logD("Successfully built ${albums.size} albums")
return albums
}
/**
* Group up albums into artists. This also requires a de-duplication step due to some edge cases
* where [buildAlbums] could not detect duplicates.
*/
private fun buildArtists(albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._artistGroupingId }
for (entry in albumsByArtist) {
val templateAlbum = entry.value[0]
val artistName =
if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) {
templateAlbum._artistGroupingName
} else {
null
}
val artistAlbums = entry.value
artists.add(Artist(artistName, artistAlbums))
}
logD("Successfully built ${artists.size} artists")
return artists
}
/**
* Read all genres and link them up to the given songs. This is the code that requires me to
* make dozens of useless queries just to link genres up.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
val songsByGenre = songs.groupBy { it._mediaStoreGenreName?.hashCode() }
for (entry in songsByGenre) {
val templateSong = entry.value[0]
genres.add(Genre(templateSong._mediaStoreGenreName, entry.value))
}
logD("Successfully built ${genres.size} genres")
return genres
}
interface Backend {
fun query(context: Context): Cursor
fun loadSongs(context: Context, cursor: Cursor): Collection<Song>
}
}

View file

@ -0,0 +1,365 @@
/*
* Copyright (c) 2022 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.indexer
import android.content.Context
import android.database.Cursor
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.contentResolverSafe
/*
* This class acts as the base for most the black magic required to get a remotely sensible music
* indexing system while still optimizing for time. I would recommend you leave this module now
* before you lose your sanity trying to understand the hoops I had to jump through for this system,
* but if you really want to stay, here's a debrief on why this code is so awful.
*
* MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
* against humanity and probably a way to summon Zalgo if you look at it the wrong way.
*
* You think that if you wanted to query a song's genre from a media database, you could just put
* "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
* straightforward for this contract that was dropped on it's head as a baby. So instead, you have
* to query for each genre, query all the songs in each genre, and then iterate through those songs
* to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
* algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
* point have the devs considered that this system is absolutely insane, and instead focused on
* adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
* Music, and of course every Google Play Music user knew how great that turned out!
*
* 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? I
* sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see 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 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 been around for *21
* years.* *It can drink now.* All of my what.
*
* Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
* table, so we have to go for the less efficient "make a big query on all the songs lol" method so
* that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
* or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
* normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
* a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
* problems since I have to manage databases across version boundaries. Sometimes music will have a
* deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
* sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
* JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
*
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
* listening is. As a result, some players like Vanilla and VLC just hack their own
* pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
* Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
* harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
* even if I set aside those crippling issues and changed my indexer to that, it would face the even
* larger problem of how google keeps trying to kill the filesystem and force you into their
* ContentResolver API. In the future MediaStore could be the only system we have, which is also the
* day that greenland melts and birthdays stop happening forever.
*
* 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.
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
* to your AlgoPop StreamMix.
*
* I wish I was born in the neolithic.
*/
abstract class MediaStoreBackend : Indexer.Backend {
private var idIndex = -1
private var titleIndex = -1
private var fileIndex = -1
private var durationIndex = -1
private var yearIndex = -1
private var albumIndex = -1
private var albumIdIndex = -1
private var artistIndex = -1
private var albumArtistIndex = -1
private var dataIndex = -1
override fun query(context: Context): Cursor {
val excludedDatabase = ExcludedDatabase.getInstance(context)
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
val args = mutableListOf<String>()
// Apply the excluded directories by filtering out specific DATA values.
// DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
// so it's probably okay to use it. The only reason we would want to use
// another method is for external partitions support, but there is no demand for that.
for (path in excludedDatabase.readPaths()) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
return requireNotNull(
context.contentResolverSafe.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selector,
args.toTypedArray(),
null)) { "Content resolver failure: No Cursor returned" }
}
override fun loadSongs(context: Context, cursor: Cursor): Collection<Song> {
val audios = mutableListOf<Audio>()
while (cursor.moveToNext()) {
audios.add(buildAudio(context, cursor))
}
context.contentResolverSafe
.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME),
null,
null,
null)
?.use { genreCursor ->
val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
while (genreCursor.moveToNext()) {
// Genre names can be a normal name, an ID3v2 constant, or null. Normal names
// are resolved as usual, but null values don't make sense and are often junk
// anyway, so we skip genres that have them.
val id = genreCursor.getLong(idIndex)
val name = genreCursor.getStringOrNull(nameIndex) ?: continue
linkGenreAudios(context, id, name, audios)
}
}
return audios.map { it.toSong() }
}
private fun linkGenreAudios(
context: Context,
genreId: Long,
genreName: String,
audios: List<Audio>
) {
context.contentResolverSafe
.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null,
null,
null)
?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
audios.find { it.id == id }?.let { song -> song.genre = genreName }
}
}
}
open val projection: Array<String>
get() = PROJECTION
open fun buildAudio(context: Context, cursor: Cursor): Audio {
// Initialize our cursor indices if we haven't already.
if (idIndex == -1) {
// We need to initialize the cursor indices.
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
}
val audio = Audio()
audio.id = cursor.getLong(idIndex)
audio.title = cursor.getString(titleIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system. Once again though, OEM issues get in our way and
// this field isn't available on some platforms. In that case, see if we can
// grok a file name from the DATA field.
audio.displayName =
cursor.getStringOrNull(fileIndex)
?: cursor
.getStringOrNull(dataIndex)
?.substringAfterLast('/', MediaStore.UNKNOWN_STRING)
?: MediaStore.UNKNOWN_STRING
audio.duration = cursor.getLong(durationIndex)
audio.year = cursor.getIntOrNull(yearIndex)
audio.album = cursor.getStringOrNull(albumIndex)
audio.albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on.
audio.artist =
cursor.getStringOrNull(artistIndex)?.run {
if (this != MediaStore.UNKNOWN_STRING) {
this
} else {
null
}
}
audio.albumArtist = cursor.getStringOrNull(albumArtistIndex)
return audio
}
/**
* Represents a song as it is represented by MediaStore. This is progressively mutated over
* several steps of the music loading process until it is complete enough to be transformed into
* a song.
*/
data class Audio(
var id: Long? = null,
var title: String? = null,
var displayName: String? = null,
var duration: Long? = null,
var track: Int? = null,
var disc: Int? = null,
var year: Int? = null,
var album: String? = null,
var albumId: Long? = null,
var artist: String? = null,
var albumArtist: String? = null,
var genre: String? = null
) {
fun toSong(): Song =
Song(
requireNotNull(title) { "Malformed song: No title" },
requireNotNull(displayName) { "Malformed song: No file name" },
requireNotNull(duration) { "Malformed song: No duration" },
track,
disc,
requireNotNull(id) { "Malformed song: No song id" },
year,
requireNotNull(album) { "Malformed song: No album name" },
requireNotNull(albumId) { "Malformed song: No album id" },
artist,
albumArtist,
genre)
}
companion object {
/**
* The album_artist MediaStore field has existed since at least API 21, but until API 30 it
* was a proprietary extension for Google Play Music and was not documented. Since this
* field probably works on all versions Auxio supports, we suppress the warning about using
* a possibly-unsupported constant.
*/
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
private val PROJECTION =
arrayOf(
MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST,
MediaStore.Audio.AudioColumns.DATA)
}
}
class Api21MediaStoreBackend : MediaStoreBackend() {
private var trackIndex = -1
override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildAudio(context: Context, cursor: Cursor): Audio {
val audio = super.buildAudio(context, cursor)
// Initialize the TRACK index if we have not already.
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
}
// TRACK is formatted as DTTT where D is the disc number and T is the track number.
// Except on Android 10. For some reason it's bugged on that version.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
audio.track = rawTrack % 1000
// A disc number of 0 means that there is no disc.
val disc = rawTrack / 1000
if (disc > 0) {
audio.disc = disc
}
}
return audio
}
}
@RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreBackend : MediaStoreBackend() {
private var trackIndex: Int = -1
private var discIndex: Int = -1
override val projection: Array<String>
get() =
super.projection +
arrayOf(
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun buildAudio(context: Context, cursor: Cursor): Audio {
val audio = super.buildAudio(context, cursor)
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
}
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while leaving out the
// total, as we have no use for it.
cursor
.getStringOrNull(trackIndex)
?.split('/', limit = 2)
?.getOrNull(0)
?.toIntOrNull()
?.let { audio.track = it }
cursor.getStringOrNull(discIndex)?.split('/', limit = 2)?.getOrNull(0)?.toIntOrNull()?.let {
audio.disc = it
}
return audio
}
}

View file

@ -25,21 +25,20 @@ import kotlin.math.max
import org.oxycblt.auxio.databinding.ViewSeekBarBinding
import org.oxycblt.auxio.util.formatDuration
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.textSafe
/**
* A wrapper around [Slider] that shows not only position and duration values, but also basically
* hacks in behavior consistent with a normal SeekBar in a way that will not crash the app.
*
* SeekBar, like most android OS components, is a version-specific mess that requires constant
* hacks on older versions. Instead, we use the more "modern" slider component, but it is not
* designed for the job that Auxio's progress bar has. It does not gracefully degrade when
* positions don't make sense (which happens incredibly often), it just crashes the entire app,
* which is insane but also checks out for something more meant for configuration than seeking.
* SeekBar, like most android OS components, is a version-specific mess that requires constant hacks
* on older versions. Instead, we use the more "modern" [Slider] component, but it is not designed
* for the job that Auxio's progress bar has. It does not gracefully degrade when positions don't
* make sense (which happens incredibly often), it just crashes the entire app, which is insane but
* also checks out for something more meant for configuration than seeking.
*
* Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the
* app or result in blatantly janky behavior. Mostly.
* Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the app
* or result in blatantly janky behavior. Mostly.
*
* @author OxygenCobalt
*/
@ -78,8 +77,8 @@ constructor(
}
/**
* The current duration, in seconds. This is the end value of the SeekBar and is indicated
* by the end TextView in the layout.
* The current duration, in seconds. This is the end value of the SeekBar and is indicated by
* the end TextView in the layout.
*/
var durationSecs: Long
get() = binding.seekBarSlider.valueTo.toLong()
@ -117,8 +116,7 @@ constructor(
interface Callback {
/**
* Called when a seek event was completed and the new position must be seeked to by
* the app.
* Called when a seek event was completed and the new position must be seeked to by the app.
*/
fun seekTo(positionSecs: Long)
}

View file

@ -58,7 +58,7 @@ class NotificationComponent(
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_channel_name),
context.getString(R.string.info_playback_channel_name),
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
@ -81,7 +81,7 @@ class NotificationComponent(
}
fun renotify() {
notificationManager.notify(IntegerTable.NOTIFICATION_CODE, build())
notificationManager.notify(IntegerTable.PLAYBACK_NOTIFICATION_CODE, build())
}
fun release() {

View file

@ -154,9 +154,7 @@ class PlaybackService :
logD("Service created")
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int) = START_NOT_STICKY
// No binding, service is headless
// Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
@ -317,7 +315,7 @@ class PlaybackService :
logD("Starting foreground/notifying")
if (!isForeground) {
startForeground(IntegerTable.NOTIFICATION_CODE, component.build())
startForeground(IntegerTable.PLAYBACK_NOTIFICATION_CODE, component.build())
isForeground = true
} else {
// If we are already in foreground just update the notification

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels -->
<string name="info_app_desc">مشغل موسيقى بسيط ومعقول لنظام الاندرويد.</string>
<string name="info_channel_name">تشغيل الموسيقى</string>
<string name="info_playback_channel_name">تشغيل الموسيقى</string>
<string name="info_widget_desc">عرض وتحكم بشتغيل الموسيقى</string>
<!-- Label Namespace | Static Labels -->

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels -->
<string name="info_app_desc">"Jednoduchý, rozumný hudební přehrávač pro Android."</string>
<string name="info_channel_name">"Přehrávání hudby"</string>
<string name="info_playback_channel_name">"Přehrávání hudby"</string>
<string name="info_widget_desc">"Zobrazit a ovládat hrající hudbu"</string>
<!-- Label Namespace | Static Labels -->

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Info namespace | App labels -->
<string name="info_channel_name">Musikwiedergabe</string>
<string name="info_playback_channel_name">Musikwiedergabe</string>
<!-- Label Namespace | Static Labels -->
<string name="lbl_retry">Erneut versuchen</string>

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels -->
<string name="info_app_desc">Un reproductor de música simple y racional para Android.</string>
<string name="info_channel_name">Reproducción musical</string>
<string name="info_playback_channel_name">Reproducción musical</string>
<string name="info_widget_desc">Ver y controlar la reproducción musical</string>
<!-- Label Namespace | Static Labels -->

View file

@ -2,7 +2,7 @@
<resources>
<!-- Info namespace | App labels -->
<string name="info_app_desc">Un semplice, razionale lettore musicale per android.</string>
<string name="info_channel_name">Riproduzione musicale</string>
<string name="info_playback_channel_name">Riproduzione musicale</string>
<string name="info_widget_desc">Vedi e gestisci la riproduzione musicale</string>
<!-- Label Namespace | Static Labels -->

View file

@ -2,7 +2,7 @@
<resources>
<!-- Info namespace | App labels -->
<string name="info_app_desc">Een eenvoudige, rationele muziekspeler voor Android.</string>
<string name="info_channel_name">Muziek Afspelen</string>
<string name="info_playback_channel_name">Muziek Afspelen</string>
<!-- Label Namespace | Static Labels -->
<string name="lbl_retry">Opnieuw proberen</string>

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels -->
<string name="info_app_desc">Простой и рациональный музыкальный проигрыватель</string>
<string name="info_channel_name">Воспроизведение</string>
<string name="info_playback_channel_name">Воспроизведение</string>
<string name="info_widget_desc">Настройки воспроизведения</string>
<!-- Label Namespace | Static Labels -->

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels -->
<string name="info_app_desc">一款简洁、克制的 Android 音乐播放器。</string>
<string name="info_channel_name">音乐播放</string>
<string name="info_playback_channel_name">音乐播放</string>
<string name="info_widget_desc">查看并控制音乐播放</string>
<!-- Label Namespace | Static Labels -->

View file

@ -2,7 +2,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<!-- Info namespace | App labels -->
<string name="info_app_desc">A simple, rational music player for android.</string>
<string name="info_channel_name">Music Playback</string>
<string name="info_playback_channel_name">Music Playback</string>
<string name="info_widget_desc">View and control music playback</string>
<!-- Label Namespace | Static Labels -->