Merge branch '3.5.0' into dev

This commit is contained in:
Alexander Capehart 2024-06-20 22:00:00 -06:00
commit e764e8b4e4
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
44 changed files with 274 additions and 128 deletions

View file

@ -2,9 +2,9 @@ name: Android CI
on: on:
push: push:
branches: [ "dev" ] branches: []
pull_request: pull_request:
branches: [ "dev" ] branches: []
jobs: jobs:
build: build:

View file

@ -8,10 +8,16 @@
#### What's Improved #### What's Improved
- Album covers are now loaded on a per-song basis - Album covers are now loaded on a per-song basis
- Correctly interpret MP4 sort tags - MP4 sort tags are now correctly interpreted
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
- M3U paths are now interpreted both as relative and absolute regardless of the format
- Added support for M3U paths starting with /storage/
#### What's Fixed #### What's Fixed
- Fixed repeat mode not restoring on startup - Fixed repeat mode not restoring on startup
- Fixed rewinding not occuring when skipping back at the beginning of the queue if
rewind before skipping was turned off
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
#### What's Changed #### What's Changed
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
@ -20,6 +26,11 @@
#### dev -> dev1 changes #### dev -> dev1 changes
- Re-added ability to open app from clicking on notification - Re-added ability to open app from clicking on notification
- Removed tasker plugin - Removed tasker plugin
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
- M3U paths are now interpreted both as relative and absolute regardless of the format
- Added support for M3U paths starting with /storage/
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
- Made album cover keying more efficient at the cost of resillients
## 3.4.3 ## 3.4.3

View file

@ -157,8 +157,8 @@ constructor(
} }
private suspend fun extractQualityCover(cover: Cover.Embedded) = private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractAospMetadataCover(cover) extractExoplayerCover(cover)
?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover) ?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? = private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run { MediaMetadataRetriever().run {

View file

@ -26,6 +26,7 @@ import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint import android.graphics.Paint
import java.math.BigInteger import java.math.BigInteger
@Suppress("UNUSED")
fun Bitmap.dHash(hashSize: Int = 16): String { fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size // Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true) val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)

View file

@ -164,7 +164,10 @@ constructor(
} }
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) val songs =
importedPlaylist.paths.mapNotNull {
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
}
if (songs.isEmpty()) { if (songs.isEmpty()) {
logE("No songs found") logE("No songs found")

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 45, exportSchema = false) @Database(entities = [CachedSong::class], version = 46, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }

View file

@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
* @see ExternalPlaylistManager * @see ExternalPlaylistManager
* @see M3U * @see M3U
*/ */
data class ImportedPlaylist(val name: String?, val paths: List<Path>) data class ImportedPlaylist(val name: String?, val paths: List<PossiblePaths>)
typealias PossiblePaths = List<Path>
class ExternalPlaylistManagerImpl class ExternalPlaylistManagerImpl
@Inject @Inject

View file

@ -29,9 +29,12 @@ import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.Volume
import org.oxycblt.auxio.music.fs.VolumeManager
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* Minimal M3U file format implementation. * Minimal M3U file format implementation.
@ -72,10 +75,16 @@ interface M3U {
} }
} }
class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U { class M3UImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val volumeManager: VolumeManager
) : M3U {
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val volumes = volumeManager.getVolumes()
val reader = BufferedReader(InputStreamReader(stream)) val reader = BufferedReader(InputStreamReader(stream))
val paths = mutableListOf<Path>() val paths = mutableListOf<PossiblePaths>()
var name: String? = null var name: String? = null
consumeFile@ while (true) { consumeFile@ while (true) {
@ -112,39 +121,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
} }
// There is basically no formal specification of file paths in M3U, and it differs // There is basically no formal specification of file paths in M3U, and it differs
// based on the US that generated it. These are the paths though that I assume most // based on the programs that generated it. I more or less have to consider any possible
// programs will generate. // interpretation as valid.
val components = val interpretations = interpretPath(path)
when { val possibilities =
path.startsWith('/') -> { interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) }
// Unix absolute path. Note that we still assume this absolute path is in
// the same volume as the M3U file. There's no sane way to map the volume
// to the phone's volumes, so this is the only thing we can do.
Components.parseUnix(path)
}
path.startsWith("./") -> {
// Unix relative path, resolve it
Components.parseUnix(path).absoluteTo(workingDirectory.components)
}
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> {
// Windows absolute path, we should get rid of the volume prefix, but
// otherwise
// the rest should be fine. Again, we have to disregard what the volume
// actually
// is since there's no sane way to map it to the phone's volumes.
Components.parseWindows(path.substring(2))
}
path.startsWith(".\\") -> {
// Windows relative path, we need to remove the .\\ prefix
Components.parseWindows(path).absoluteTo(workingDirectory.components)
}
else -> {
// No clue, parse by all separators and assume it's relative.
Components.parseAny(path).absoluteTo(workingDirectory.components)
}
}
paths.add(Path(workingDirectory.volume, components)) paths.add(possibilities)
} }
return if (paths.isNotEmpty()) { return if (paths.isNotEmpty()) {
@ -155,6 +138,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
} }
} }
private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean)
private fun interpretPath(path: String): List<InterpretedPath> =
when {
path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true))
path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false))
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) ->
listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true))
path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true))
path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false))
else ->
listOf(
InterpretedPath(Components.parseUnix(path), false),
InterpretedPath(Components.parseWindows(path), true))
}
private fun expandInterpretation(
path: InterpretedPath,
workingDirectory: Path,
volumes: List<Volume>
): List<Path> {
val absoluteInterpretation = Path(workingDirectory.volume, path.components)
val relativeInterpretation =
Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components))
val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true }
val volumeInterpretation =
volumeExactMatch?.let {
val components =
unlikelyToBeNull(volumeExactMatch.components).containing(path.components)
Path(volumeExactMatch, components)
}
return if (path.likelyAbsolute) {
listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation)
} else {
listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation)
}
}
override fun write( override fun write(
playlist: Playlist, playlist: Playlist,
outputStream: OutputStream, outputStream: OutputStream,

View file

@ -158,6 +158,8 @@ value class Components private constructor(val components: List<String>) {
return components == other.components.take(components.size) return components == other.components.take(components.size)
} }
fun containing(other: Components) = Components(other.components.drop(components.size))
companion object { companion object {
/** /**
* Parses a path string into a [Components] instance by the unix path separator (/). * Parses a path string into a [Components] instance by the unix path separator (/).

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.music.metadata package org.oxycblt.auxio.music.metadata
import android.graphics.BitmapFactory
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.MetadataRetriever
@ -26,8 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future import java.util.concurrent.Future
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min
import org.oxycblt.auxio.image.extractor.CoverExtractor import org.oxycblt.auxio.image.extractor.CoverExtractor
import org.oxycblt.auxio.image.extractor.dHash
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
@ -106,10 +105,24 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) populateWithVorbis(textTags.vorbis)
val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata) coverExtractor.findCoverDataInMetadata(metadata)?.use {
val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } val available = it.available()
rawSong.coverPerceptualHash = bitmap?.dHash() val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
bitmap?.recycle() it.skip(skip)
val bytes = ByteArray(COVER_KEY_SAMPLE)
it.read(bytes)
@OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString()
rawSong.coverPerceptualHash = byteString
}
// OPTIONAL: Nicer cover art keying using an actual perceptual hash
// Really bad idea if you have big cover arts. Okay idea if you have different
// formats for the same cover art.
// val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
// rawSong.coverPerceptualHash = bitmap?.dHash()
// bitmap?.recycle()
// OPUS base gain interpretation code: This is likely not needed, as the media player // OPUS base gain interpretation code: This is likely not needed, as the media player
// should be using the base gain already. Uncomment if that's not the case. // should be using the base gain already. Uncomment if that's not the case.
@ -140,7 +153,9 @@ private class TagWorkerImpl(
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 { rawSong.musicBrainzId = it.first() } (textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
@ -170,7 +185,9 @@ private class TagWorkerImpl(
?.let { rawSong.date = it } ?.let { rawSong.date = it }
// Album // Album
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() } (textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
rawSong.albumMusicBrainzId = it.first()
}
textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] (textFrames["TXXX:musicbrainz album type"]
@ -180,7 +197,9 @@ private class TagWorkerImpl(
?.let { rawSong.releaseTypes = it } ?.let { rawSong.releaseTypes = it }
// Artist // Artist
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
rawSong.artistMusicBrainzIds = it
}
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artistssort"] (textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
@ -188,9 +207,9 @@ private class TagWorkerImpl(
?.let { rawSong.artistSortNames = it } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { (textFrames["TXXX:musicbrainz album artist id"]
rawSong.albumArtistMusicBrainzIds = it ?: textFrames["TXXX:musicbrainz_albumartistid"])
} ?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"] (textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]) ?: textFrames["TPE2"])
@ -261,7 +280,9 @@ private class TagWorkerImpl(
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // Song
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } (comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
rawSong.musicBrainzId = it.first()
}
comments["title"]?.let { rawSong.name = it.first() } comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() } comments["titlesort"]?.let { rawSong.sortName = it.first() }
@ -290,20 +311,28 @@ private class TagWorkerImpl(
?.let { rawSong.date = it } ?.let { rawSong.date = it }
// Album // Album
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() } (comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
rawSong.albumMusicBrainzId = it.first()
}
comments["album"]?.let { rawSong.albumName = it.first() } comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() } comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { rawSong.releaseTypes = it } (comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
rawSong.releaseTypes = it
}
// Artist // Artist
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } (comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
rawSong.artistMusicBrainzIds = it
}
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"] (comments["artistssort"]
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
?.let { rawSong.artistSortNames = it } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(comments["albumartists"] (comments["albumartists"]
?: comments["album_artists"] ?: comments["album artists"] ?: comments["album_artists"] ?: comments["album artists"]
?: comments["albumartist"]) ?: comments["albumartist"])
@ -360,6 +389,8 @@ private class TagWorkerImpl(
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull() first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
private companion object { private companion object {
val COVER_KEY_SAMPLE = 32
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation") val COMPILATION_RELEASE_TYPES = listOf("compilation")

View file

@ -19,6 +19,9 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -29,6 +32,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -57,8 +61,6 @@ constructor(
private var invalidator: Invalidator? = null private var invalidator: Invalidator? = null
interface Invalidator { interface Invalidator {
data class ParentId(val id: String, val itemCount: Int)
fun invalidate(ids: Map<String, Int>) fun invalidate(ids: Map<String, Int>)
fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
@ -213,27 +215,41 @@ constructor(
return when (val item = musicRepository.find(uid)) { return when (val item = musicRepository.find(uid)) {
is Album -> { is Album -> {
val songs = listSettings.albumSongSort.songs(item.songs) val songs = listSettings.albumSongSort.songs(item.songs)
songs.map { it.toMediaItem(context, item) } songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} }
is Artist -> { is Artist -> {
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
val songs = listSettings.artistSongSort.songs(item.songs) val songs = listSettings.artistSongSort.songs(item.songs)
albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} }
is Genre -> { is Genre -> {
val artists = GENRE_ARTISTS_SORT.artists(item.artists) val artists = GENRE_ARTISTS_SORT.artists(item.artists)
val songs = listSettings.genreSongSort.songs(item.songs) val songs = listSettings.genreSongSort.songs(item.songs)
artists.map { it.toMediaItem(context) } + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
songs.map { it.toMediaItem(context, null) } songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
} }
is Playlist -> { is Playlist -> {
item.songs.map { it.toMediaItem(context, item) } item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
} }
is Song, is Song,
null -> return null null -> return null
} }
} }
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
val oldExtras = mediaMetadata.extras ?: Bundle()
val newExtras =
Bundle(oldExtras).apply {
putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.getString(res))
}
return buildUpon()
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
.build()
}
private fun getCategorySize( private fun getCategorySize(
category: MediaSessionUID.Category, category: MediaSessionUID.Category,
musicRepository: MusicRepository musicRepository: MusicRepository

View file

@ -19,10 +19,15 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.media.utils.MediaConstants
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import java.io.ByteArrayOutputStream
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -37,14 +42,27 @@ import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
// TODO: Make custom overflow menu for compat
val style =
Bundle().apply {
putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
}
val metadata = val metadata =
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(context.getString(nameRes)) .setTitle(context.getString(nameRes))
.setIsPlayable(false) .setIsPlayable(false)
.setIsBrowsable(true) .setIsBrowsable(true)
.setMediaType(mediaType) .setMediaType(mediaType)
.build() .setExtras(style)
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() if (bitmapRes != null) {
val data = ByteArrayOutputStream()
BitmapFactory.decodeResource(context.resources, bitmapRes)
.compress(Bitmap.CompressFormat.PNG, 100, data)
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
}
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
} }
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
@ -103,7 +121,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setReleaseMonth(dates?.min?.month) .setReleaseMonth(dates?.min?.month)
.setReleaseDay(dates?.min?.day) .setReleaseDay(dates?.min?.day)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true) .setIsPlayable(false)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover.single.mediaStoreCoverUri) .setArtworkUri(cover.single.mediaStoreCoverUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
@ -133,7 +151,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
})) }))
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.setIsPlayable(true) .setIsPlayable(false)
.setIsBrowsable(true) .setIsBrowsable(true)
.setGenre(genres.resolveNames(context)) .setGenre(genres.resolveNames(context))
.setArtworkUri(cover.single.mediaStoreCoverUri) .setArtworkUri(cover.single.mediaStoreCoverUri)
@ -157,7 +175,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
}) })
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true) .setIsPlayable(false)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover.single.mediaStoreCoverUri) .setArtworkUri(cover.single.mediaStoreCoverUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
@ -180,7 +198,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
context.getString(R.string.def_song_count) context.getString(R.string.def_song_count)
}) })
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true) .setIsPlayable(false)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover?.single?.mediaStoreCoverUri) .setArtworkUri(cover?.single?.mediaStoreCoverUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
@ -205,14 +223,38 @@ fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
} }
sealed interface MediaSessionUID { sealed interface MediaSessionUID {
enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : enum class Category(
MediaSessionUID { val id: String,
ROOT("root", R.string.info_app_name, null), @StringRes val nameRes: Int,
SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), @DrawableRes val bitmapRes: Int?,
ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), val mediaType: Int?
ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), ) : MediaSessionUID {
GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), ROOT("root", R.string.info_app_name, null, null),
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); SONGS(
"songs",
R.string.lbl_songs,
R.drawable.ic_song_bitmap_24,
MediaMetadata.MEDIA_TYPE_MUSIC),
ALBUMS(
"albums",
R.string.lbl_albums,
R.drawable.ic_album_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
ARTISTS(
"artists",
R.string.lbl_artists,
R.drawable.ic_artist_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
GENRES(
"genres",
R.string.lbl_genres,
R.drawable.ic_genre_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
PLAYLISTS(
"playlists",
R.string.lbl_playlists,
R.drawable.ic_playlist_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
override fun toString() = "$ID_CATEGORY:$id" override fun toString() = "$ID_CATEGORY:$id"

View file

@ -270,8 +270,10 @@ class ExoPlaybackStateHolder(
override fun prev() { override fun prev() {
if (playbackSettings.rewindWithPrev) { if (playbackSettings.rewindWithPrev) {
player.seekToPrevious() player.seekToPrevious()
} else { } else if (player.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem() player.seekToPreviousMediaItem()
} else {
player.seekTo(0)
} }
if (!playbackSettings.rememberPause) { if (!playbackSettings.rememberPause) {
player.play() player.play()
@ -365,17 +367,28 @@ class ExoPlaybackStateHolder(
rawQueue: RawQueue, rawQueue: RawQueue,
ack: StateAck.NewPlayback? ack: StateAck.NewPlayback?
) { ) {
this.parent = parent logD("Applying saved state")
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) var sendEvent = false
if (rawQueue.isShuffled) { if (this.parent != parent) {
player.shuffleModeEnabled = true this.parent = parent
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) sendEvent = true
} else { }
player.shuffleModeEnabled = false if (rawQueue != resolveQueue()) {
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
if (rawQueue.isShuffled) {
player.shuffleModeEnabled = true
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
} else {
player.shuffleModeEnabled = false
}
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
player.prepare()
player.pause()
sendEvent = true
}
if (sendEvent) {
ack?.let { playbackManager.ack(this, it) }
} }
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
player.prepare()
ack?.let { playbackManager.ack(this, it) }
} }
override fun endSession() { override fun endSession() {

View file

@ -173,6 +173,15 @@ class MediaSessionPlayer(
playbackManager.repeatMode(appRepeatMode) playbackManager.repeatMode(appRepeatMode)
} }
override fun seekToDefaultPosition(mediaItemIndex: Int) {
val indices = unscrambleQueueIndices()
val fakeIndex = indices.indexOf(mediaItemIndex)
if (fakeIndex < 0) {
return
}
playbackManager.goto(fakeIndex)
}
override fun seekToNext() = playbackManager.next() override fun seekToNext() = playbackManager.next()
override fun seekToNextMediaItem() = playbackManager.next() override fun seekToNextMediaItem() = playbackManager.next()
@ -183,18 +192,9 @@ class MediaSessionPlayer(
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
override fun seekTo(mediaItemIndex: Int, positionMs: Long) { override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed()
val indices = unscrambleQueueIndices()
val fakeIndex = indices.indexOf(mediaItemIndex) override fun seekToDefaultPosition() = notAllowed()
if (fakeIndex < 0) {
return
}
playbackManager.goto(fakeIndex)
if (positionMs == C.TIME_UNSET) {
return
}
playbackManager.seekTo(positionMs)
}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) { override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
@ -278,10 +278,6 @@ class MediaSessionPlayer(
override fun setPlaybackSpeed(speed: Float) = notAllowed() override fun setPlaybackSpeed(speed: Float) = notAllowed()
override fun seekToDefaultPosition() = notAllowed()
override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed()
override fun seekForward() = notAllowed() override fun seekForward() = notAllowed()
override fun seekBack() = notAllowed() override fun seekBack() = notAllowed()

View file

@ -129,7 +129,7 @@ constructor(
fun release() { fun release() {
waitJob.cancel() waitJob.cancel()
mediaSession.release() mediaItemBrowser.release()
actionHandler.release() actionHandler.release()
exoHolder.release() exoHolder.release()
playbackManager.removeListener(this) playbackManager.removeListener(this)

View file

@ -49,13 +49,15 @@ constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
private val systemReceiver: SystemPlaybackReceiver private val widgetComponent: WidgetComponent
) : PlaybackStateManager.Listener, PlaybackSettings.Listener { ) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
interface Callback { interface Callback {
fun onCustomLayoutChanged(layout: List<CommandButton>) fun onCustomLayoutChanged(layout: List<CommandButton>)
} }
private val systemReceiver =
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
private var callback: Callback? = null private var callback: Callback? = null
fun attach(callback: Callback) { fun attach(callback: Callback) {
@ -71,6 +73,7 @@ constructor(
playbackManager.removeListener(this) playbackManager.removeListener(this)
playbackSettings.unregisterListener(this) playbackSettings.unregisterListener(this)
context.unregisterReceiver(systemReceiver) context.unregisterReceiver(systemReceiver)
widgetComponent.release()
} }
fun withCommands(commands: SessionCommands) = fun withCommands(commands: SessionCommands) =
@ -180,12 +183,10 @@ object PlaybackActions {
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
* active [IntentFilter] to be registered. * active [IntentFilter] to be registered.
*/ */
class SystemPlaybackReceiver class SystemPlaybackReceiver(
@Inject private val playbackManager: PlaybackStateManager,
constructor( private val playbackSettings: PlaybackSettings,
val playbackManager: PlaybackStateManager, private val widgetComponent: WidgetComponent
val playbackSettings: PlaybackSettings,
val widgetComponent: WidgetComponent
) : BroadcastReceiver() { ) : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false private var initialHeadsetPlugEventHandled = false

View file

@ -91,7 +91,8 @@ constructor(
override val shuffled: Boolean override val shuffled: Boolean
) : PlaybackCommand ) : PlaybackCommand
override fun song(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) override fun song(song: Song, shuffle: ShuffleMode) =
newCommand(song, null, listOf(song), shuffle)
override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
@ -105,7 +106,7 @@ constructor(
newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle) newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle)
override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) = override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) =
newCommand(song, playlist, playlist.songs, listSettings.playlistSort, shuffle) newCommand(song, playlist, playlist.songs, shuffle)
override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle) override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle)

View file

@ -795,15 +795,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
index index
}) })
// Valid state where something needs to be played, direct the stateholder to apply stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
// this new state. stateHolder.seekTo(savedState.positionMs)
val oldStateMirror = stateMirror
if (oldStateMirror.rawQueue != rawQueue) {
logD("Queue changed, must reload player")
stateHolder.playing(false)
stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback)
stateHolder.seekTo(savedState.positionMs)
}
stateHolder.repeatMode(savedState.repeatMode) stateHolder.repeatMode(savedState.repeatMode)
isInitialized = true isInitialized = true

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.oxycblt.auxio.BuildConfig
/** /**
* A wrapper around [StateFlow] exposing a one-time consumable event. * A wrapper around [StateFlow] exposing a one-time consumable event.
@ -166,7 +167,13 @@ suspend fun <E> SendChannel<E>.sendWithTimeout(element: E, timeout: Long = DEFAU
try { try {
withTimeout(timeout) { send(element) } withTimeout(timeout) { send(element) }
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
throw TimeoutException("Timed out sending element $element to channel: $e") logE("Failed to send element to channel $e in ${timeout}ms.")
if (BuildConfig.DEBUG) {
throw TimeoutException("Timed out sending element to channel: $e")
} else {
logE(e.stackTraceToString())
send(element)
}
} }
} }
@ -203,7 +210,13 @@ suspend fun <E> ReceiveChannel<E>.forEachWithTimeout(
subsequent = true subsequent = true
} }
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
throw TimeoutException("Timed out receiving element from channel: $e") logE("Failed to send element to channel $e in ${timeout}ms.")
if (BuildConfig.DEBUG) {
throw TimeoutException("Timed out sending element to channel: $e")
} else {
logE(e.stackTraceToString())
handler()
}
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B