Merge branch 'media3' into dev

This commit is contained in:
Alexander Capehart 2024-04-20 15:04:51 -06:00
commit b4cf6a9563
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
22 changed files with 365 additions and 141 deletions

View file

@ -94,7 +94,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(listOf(song))
.data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL))
.target(

View file

@ -48,6 +48,7 @@ import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album
@ -99,14 +100,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val shapeAppearance: ShapeAppearanceModel
private data class Cover(
val songs: Collection<Song>,
val desc: String,
@DrawableRes val errorRes: Int
)
private var currentCover: Cover? = null
init {
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
@ -346,8 +339,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param song The [Song] to bind to the view.
*/
fun bind(song: Song) =
bind(
listOf(song),
bindImpl(
listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24)
@ -357,8 +350,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param album The [Album] to bind to the view.
*/
fun bind(album: Album) =
bind(
album.songs,
bindImpl(
album.cover.all,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@ -368,8 +361,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param artist The [Artist] to bind to the view.
*/
fun bind(artist: Artist) =
bind(
artist.songs,
bindImpl(
artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@ -379,8 +372,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param genre The [Genre] to bind to the view.
*/
fun bind(genre: Genre) =
bind(
genre.songs,
bindImpl(
genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@ -390,8 +383,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param playlist the [Playlist] to bind.
*/
fun bind(playlist: Playlist) =
bind(
playlist.songs,
bindImpl(
playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@ -402,10 +395,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param desc The content description to describe the bound data.
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
fun bind(songs: Collection<Song>, desc: String, @DrawableRes errorRes: Int) {
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.order(songs), desc, errorRes)
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(songs)
.data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
.target(image)
@ -423,7 +419,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(image)
imageLoader.enqueue(request.build())
contentDescription = desc
currentCover = Cover(songs, desc, errorRes)
}
/**
@ -436,7 +431,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Px val iconSize: Int?
) : Drawable() {
init {
// Re-tint the drawable to use the analogous "on surface" color for
// Re-tint the drawable to use the analogous "on surfaceg" color for
// StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}

View file

@ -24,25 +24,23 @@ import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
import org.oxycblt.auxio.music.Song
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
Keyer<Collection<Song>> {
override fun key(data: Collection<Song>, options: Options) =
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
override fun key(data: Collection<Cover>, options: Options) =
"${data.map { it.perceptualHash }.hashCode()}"
}
class SongCoverFetcher
class CoverFetcher
private constructor(
private val songs: Collection<Song>,
private val covers: Collection<Cover>,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(songs, size)
override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Song>> {
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor)
Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor)
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverUri.kt is part of Auxio.
* Cover.kt is part of Auxio.
*
* 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
@ -19,14 +19,36 @@
package org.oxycblt.auxio.image.extractor
import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
/**
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
* images.
*
* @param mediaStore The album cover [Uri] obtained from MediaStore.
* @param mediaStoreUri The album cover [Uri] obtained from MediaStore.
* @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
* an album cover.
* @author Alexander Capehart (OxygenCobalt)
*/
data class CoverUri(val mediaStore: Uri, val song: Uri)
data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) {
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.perceptualHash }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -27,6 +27,7 @@ import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
@ -50,8 +51,6 @@ import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logE
@ -70,17 +69,16 @@ constructor(
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param songs The [Song]s to load.
* @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? {
val albums = computeCoverOrdering(songs)
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (album in albums) {
openCoverInputStream(album)?.let(streams::add)
for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
@ -108,71 +106,7 @@ constructor(
dataSource = DataSource.DISK)
}
/**
* Creates an [Album] list representing the order that album covers would be used in [extract].
*
* @param songs A hypothetical list of [Song]s that would be used in [extract].
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
* by their names. "Representation" is defined by how many [Song]s were found to be linked to
* the given [Album] in the given [Song] list.
*/
fun computeCoverOrdering(songs: Collection<Song>): List<Album> {
// TODO: Start short-circuiting in more places
if (songs.isEmpty()) return listOf()
if (songs.size == 1) return listOf(songs.first().album)
val sortedMap =
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
for (song in songs) {
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
}
return sortedMap.keys.sortedByDescending { sortedMap[it] }
}
private suspend fun openCoverInputStream(album: Album) =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
CoverMode.QUALITY -> extractQualityCover(album)
}
} catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(album: Album) =
extractAospMetadataCover(album)
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
private fun extractAospMetadataCover(album: Album): InputStream? =
MediaMetadataRetriever().run {
// 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.rmt
setDataSource(context, album.coverUri.song)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(album: Album): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
@ -204,12 +138,56 @@ constructor(
return stream
}
private suspend fun extractMediaStoreCover(album: Album) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(album.coverUri.mediaStore)
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
} catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover) =
extractAospMetadataCover(cover)
?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover): InputStream? =
MediaMetadataRetriever().run {
// 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.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreUri) }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.kt is part of Auxio.
*
* 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.image.extractor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -35,14 +35,14 @@ class ExtractorModule {
@Provides
fun imageLoader(
@ApplicationContext context: Context,
songKeyer: SongKeyer,
songFactory: SongCoverFetcher.Factory
keyer: CoverKeyer,
factory: CoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer)
add(songFactory)
add(keyer)
add(factory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* CoilBitmapLoader.kt is part of Auxio.
* MediaSessionBitmapLoader.kt is part of Auxio.
*
* 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
@ -18,22 +18,31 @@
package org.oxycblt.auxio.image.service
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.BitmapLoader
import coil.ImageLoader
import coil.memory.MemoryCache
import coil.request.Options
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.extractor.CoverKeyer
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.service.MediaSessionUID
class MediaSessionBitmapLoader
@Inject
constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository,
private val bitmapProvider: BitmapProvider
private val bitmapProvider: BitmapProvider,
private val keyer: CoverKeyer,
private val imageLoader: ImageLoader,
) : BitmapLoader {
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
throw NotImplementedError()
@ -58,6 +67,13 @@ constructor(
else -> return null
}
?: return null
// Even launching a coroutine to obtained cached covers is enough to make the notification
// go without covers.
val key = keyer.key(listOf(song.cover), Options(context))
if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) {
future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap)
return future
}
bitmapProvider.load(
song,
object : BitmapProvider.Target {

View file

@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sign
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen
@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
0
}
override fun interpolateOutOfBoundsScroll(
recyclerView: RecyclerView,
viewSize: Int,
viewSizeOutOfBounds: Int,
totalSize: Int,
msSinceStartScroll: Long
): Int {
// Clamp the scroll speed to prevent thefrom freaking out
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
val standardSpeed =
super.interpolateOutOfBoundsScroll(
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
val clampedAbsVelocity =
max(
MINIMUM_INITIAL_DRAG_VELOCITY,
min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
}
final override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
/** The drawable of the [body] background that can be elevated. */
val background: Drawable
}
companion object {
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
}
}

View file

@ -27,7 +27,8 @@ import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.image.extractor.CoverUri
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
@ -246,6 +247,8 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe.
*/
val uri: Uri
/** Useful information to quickly obtain the album cover. */
val cover: Cover
/**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file.
@ -293,11 +296,8 @@ interface Album : MusicParent {
* [ReleaseType.Album].
*/
val releaseType: ReleaseType
/**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality.
*/
val coverUri: CoverUri
/** Cover information from the template song used for the album. */
val cover: ParentCover
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -326,6 +326,8 @@ interface Artist : MusicParent {
* songs.
*/
val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover
/** The [Genre]s of this artist. */
val genres: List<Genre>
}
@ -340,6 +342,8 @@ interface Genre : MusicParent {
val artists: Collection<Artist>
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover
}
/**
@ -352,6 +356,8 @@ interface Playlist : MusicParent {
override val songs: List<Song>
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover?
}
/**

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.splitEscaped
@Database(entities = [CachedSong::class], version = 42, exportSchema = false)
@Database(entities = [CachedSong::class], version = 45, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
@ -80,6 +80,8 @@ data class CachedSong(
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
@ -119,6 +121,8 @@ data class CachedSong(
rawSong.subtitle = subtitle
rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
@ -167,6 +171,7 @@ data class CachedSong(
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,

View file

@ -19,7 +19,8 @@
package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.CoverUri
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -112,6 +113,9 @@ class SongImpl(
override val genres: List<Genre>
get() = _genres
override val cover =
Cover(rawSong.coverPerceptualHash, requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri)
/**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album].
@ -291,9 +295,9 @@ class AlbumImpl(
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
override val durationMs: Long
override val dateAdded: Long
override val cover: ParentCover
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
@ -337,6 +341,8 @@ class AlbumImpl(
durationMs = totalDuration
dateAdded = earliestDateAdded
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
@ -419,6 +425,7 @@ class ArtistImpl(
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre>
@ -451,6 +458,14 @@ class ArtistImpl(
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
@ -528,6 +543,7 @@ class GenreImpl(
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode()
@ -545,6 +561,8 @@ class GenreImpl(
artists = distinctArtists
durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()

View file

@ -67,6 +67,8 @@ data class RawSong(
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
/** @see Song.cover */
var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */

View file

@ -102,7 +102,12 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId
*/
fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this)
fun Long.toCoverUri(): Uri =
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
appendPath(this@toCoverUri.toString())
appendPath("albumart")
build()
}
// --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.metadata
import android.graphics.BitmapFactory
import androidx.core.text.isDigitsOnly
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever
@ -25,6 +26,8 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future
import javax.inject.Inject
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.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date
@ -60,7 +63,10 @@ interface TagWorker {
class TagWorkerFactoryImpl
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
constructor(
private val mediaSourceFactory: MediaSource.Factory,
private val coverExtractor: CoverExtractor
) : TagWorker.Factory {
override fun create(rawSong: RawSong): TagWorker =
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
@ -70,12 +76,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())))
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
coverExtractor)
}
private class TagWorkerImpl(
private val rawSong: RawSong,
private val future: Future<TrackGroupArray>
private val future: Future<TrackGroupArray>,
private val coverExtractor: CoverExtractor
) : TagWorker {
override fun poll(): RawSong? {
if (!future.isDone) {
@ -98,6 +106,11 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata)
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
// should be using the base gain already. Uncomment if that's not the case.
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS

View file

@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setIsPlayable(true)
.setIsBrowsable(false)
.setArtworkUri(album.coverUri.mediaStore)
.setArtworkUri(album.cover.single.mediaStoreUri)
.setExtras(
Bundle().apply {
putString("uid", mediaSessionUID.toString())
@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(coverUri.mediaStore)
.setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
.setIsPlayable(true)
.setIsBrowsable(true)
.setGenre(genres.resolveNames(context))
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover?.single?.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
@ -46,6 +47,8 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)"
override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) }
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
*

View file

@ -106,7 +106,8 @@ class ExoPlaybackStateHolder(
private set
val mediaSessionPlayer: Player
get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository)
get() =
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
override val progression: Progression
get() {

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.playback.service
import android.content.Context
import android.os.Bundle
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
@ -31,6 +33,7 @@ import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import java.lang.Exception
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
@ -60,6 +63,7 @@ import org.oxycblt.auxio.util.logE
* @author Alexander Capehart
*/
class MediaSessionPlayer(
private val context: Context,
player: Player,
private val playbackManager: PlaybackStateManager,
private val commandFactory: PlaybackCommand.Factory,
@ -86,6 +90,20 @@ class MediaSessionPlayer(
setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET)
}
override fun getMediaMetadata() =
super.getMediaMetadata().run {
val existingExtras = extras
val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle()
newExtras.apply {
putString(
"parent",
playbackManager.parent?.name?.resolve(context)
?: context.getString(R.string.lbl_all_songs))
}
buildUpon().setExtras(newExtras).build()
}
override fun setMediaItems(
mediaItems: MutableList<MediaItem>,
startIndex: Int,

View file

@ -100,7 +100,7 @@ constructor(
}
fun handleTaskRemoved() {
if (playbackManager.progression.isPlaying) {
if (!playbackManager.progression.isPlaying) {
playbackManager.endSession()
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* SystemPlaybackReciever.kt is part of Auxio.
* PlaybackActionHandler.kt is part of Auxio.
*
* 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
@ -25,7 +25,9 @@ import android.content.IntentFilter
import android.media.AudioManager
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands
import dagger.hilt.android.qualifiers.ApplicationContext
@ -36,6 +38,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
@ -102,6 +105,13 @@ constructor(
.setDisplayName(context.getString(R.string.desc_change_repeat))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
.setEnabled(true)
.setExtras(
Bundle().apply {
putInt(
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX,
0)
})
.build())
}
ActionMode.SHUFFLE -> {
@ -113,16 +123,56 @@ constructor(
.setDisplayName(context.getString(R.string.lbl_shuffle))
.setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
.setEnabled(true)
.build())
}
else -> {}
}
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_skip_prev_24)
.setDisplayName(context.getString(R.string.desc_skip_prev))
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
.setEnabled(true)
.setExtras(
Bundle().apply {
putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1)
})
.build())
actions.add(
CommandButton.Builder()
.setIconResId(
if (playbackManager.progression.isPlaying) R.drawable.ic_pause_24
else R.drawable.ic_play_24)
.setDisplayName(context.getString(R.string.desc_play_pause))
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setEnabled(true)
.setExtras(
Bundle().apply {
putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2)
})
.build())
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_skip_next_24)
.setDisplayName(context.getString(R.string.desc_skip_next))
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
.setEnabled(true)
.setExtras(
Bundle().apply {
putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 3)
})
.build())
actions.add(
CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24)
.setDisplayName(context.getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
.setEnabled(true)
.build())
return actions
@ -133,6 +183,11 @@ constructor(
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onProgressionChanged(progression: Progression) {
super.onProgressionChanged(progression)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode)
callback?.onCustomLayoutChanged(createCustomLayout())

2
media

@ -1 +1 @@
Subproject commit bfa4c10f773bb9336d9c7dade490463318b12ab6
Subproject commit 6c77cfa13c83bf2ae5188603d2c9a51ec4cb3ac3