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 target
.onConfigRequest( .onConfigRequest(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(listOf(song)) .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)) .size(Size.ORIGINAL))
.target( .target(

View file

@ -48,6 +48,7 @@ import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R 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.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -99,14 +100,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val shapeAppearance: ShapeAppearanceModel 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 { init {
// Obtain some StyledImageView attributes to use later when theming the custom view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
@ -346,8 +339,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param song The [Song] to bind to the view. * @param song The [Song] to bind to the view.
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bind( bindImpl(
listOf(song), listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) 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. * @param album The [Album] to bind to the view.
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bind( bindImpl(
album.songs, album.cover.all,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) 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. * @param artist The [Artist] to bind to the view.
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bind( bindImpl(
artist.songs, artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) 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. * @param genre The [Genre] to bind to the view.
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bind( bindImpl(
genre.songs, genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -390,8 +383,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param playlist the [Playlist] to bind. * @param playlist the [Playlist] to bind.
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bind( bindImpl(
playlist.songs, playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) 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 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. * @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 = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(songs) .data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
.target(image) .target(image)
@ -423,7 +419,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(image) CoilUtils.dispose(image)
imageLoader.enqueue(request.build()) imageLoader.enqueue(request.build())
contentDescription = desc contentDescription = desc
currentCover = Cover(songs, desc, errorRes)
} }
/** /**
@ -436,7 +431,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Px val iconSize: Int? @Px val iconSize: Int?
) : Drawable() { ) : Drawable() {
init { 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. // StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) 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.request.Options
import coil.size.Size import coil.size.Size
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.Song
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
Keyer<Collection<Song>> { override fun key(data: Collection<Cover>, options: Options) =
override fun key(data: Collection<Song>, options: Options) = "${data.map { it.perceptualHash }.hashCode()}"
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
} }
class SongCoverFetcher class CoverFetcher
private constructor( private constructor(
private val songs: Collection<Song>, private val covers: Collection<Cover>,
private val size: Size, private val size: Size,
private val coverExtractor: CoverExtractor, private val coverExtractor: CoverExtractor,
) : Fetcher { ) : 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) : class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Song>> { Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) = override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor) CoverFetcher(data, options.size, coverExtractor)
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -19,14 +19,36 @@
package org.oxycblt.auxio.image.extractor package org.oxycblt.auxio.image.extractor
import android.net.Uri 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 * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
* images. * 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 * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
* an album cover. * an album cover.
* @author Alexander Capehart (OxygenCobalt) * @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.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame import androidx.media3.extractor.metadata.flac.PictureFrame
@ -50,8 +51,6 @@ import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings 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.music.Song
import org.oxycblt.auxio.util.logE 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. * 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. * @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult] * @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 * will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/ */
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? { suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val albums = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (album in albums) { for (cover in covers) {
openCoverInputStream(album)?.let(streams::add) openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that // 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 // does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use. // definitely have image data to use.
@ -108,71 +106,7 @@ constructor(
dataSource = DataSource.DISK) dataSource = DataSource.DISK)
} }
/** fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
* 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
}
var stream: ByteArrayInputStream? = null var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) { for (i in 0 until metadata.length()) {
@ -204,12 +138,56 @@ constructor(
return stream return stream
} }
private suspend fun extractMediaStoreCover(album: Album) = private suspend fun openCoverInputStream(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process try {
withContext(Dispatchers.IO) { when (imageSettings.coverMode) {
context.contentResolver.openInputStream(album.coverUri.mediaStore) 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 */ /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult { private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic. // 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 @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
songKeyer: SongKeyer, keyer: CoverKeyer,
songFactory: SongCoverFetcher.Factory factory: CoverFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
// Add fetchers for Music components to make them usable with ImageRequest // Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer) add(keyer)
add(songFactory) add(factory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * it under the terms of the GNU General Public License as published by
@ -18,22 +18,31 @@
package org.oxycblt.auxio.image.service package org.oxycblt.auxio.image.service
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.BitmapLoader 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.ListenableFuture
import com.google.common.util.concurrent.SettableFuture import com.google.common.util.concurrent.SettableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.extractor.CoverKeyer
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.MediaSessionUID
class MediaSessionBitmapLoader class MediaSessionBitmapLoader
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val bitmapProvider: BitmapProvider private val bitmapProvider: BitmapProvider,
private val keyer: CoverKeyer,
private val imageLoader: ImageLoader,
) : BitmapLoader { ) : BitmapLoader {
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> { override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
throw NotImplementedError() throw NotImplementedError()
@ -58,6 +67,13 @@ constructor(
else -> return null else -> return null
} }
?: 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( bitmapProvider.load(
song, song,
object : BitmapProvider.Target { object : BitmapProvider.Target {

View file

@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView 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.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
0 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( final override fun onChildDraw(
c: Canvas, c: Canvas,
recyclerView: RecyclerView, recyclerView: RecyclerView,
@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
/** The drawable of the [body] background that can be elevated. */ /** The drawable of the [body] background that can be elevated. */
val background: Drawable 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 kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize 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.list.Item
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
@ -246,6 +247,8 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe. * audio file in a way that is scoped-storage-safe.
*/ */
val uri: Uri 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 * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
@ -293,11 +296,8 @@ interface Album : MusicParent {
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** /** Cover information from the template song used for the album. */
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the val cover: ParentCover
* cost of image quality.
*/
val coverUri: CoverUri
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -326,6 +326,8 @@ interface Artist : MusicParent {
* songs. * songs.
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -340,6 +342,8 @@ interface Genre : MusicParent {
val artists: Collection<Artist> val artists: Collection<Artist>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long 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> override val songs: List<Song>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long 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.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped 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 class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }
@ -80,6 +80,8 @@ data class CachedSong(
var subtitle: String? = null, var subtitle: String? = null,
/** @see RawSong.date */ /** @see RawSong.date */
var date: Date? = null, var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */ /** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null, var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */ /** @see RawSong.albumName */
@ -119,6 +121,8 @@ data class CachedSong(
rawSong.subtitle = subtitle rawSong.subtitle = subtitle
rawSong.date = date rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName rawSong.albumName = albumName
rawSong.albumSortName = albumSortName rawSong.albumSortName = albumSortName
@ -167,6 +171,7 @@ data class CachedSong(
disc = rawSong.disc, disc = rawSong.disc,
subtitle = rawSong.subtitle, subtitle = rawSong.subtitle,
date = rawSong.date, date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId, albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName, albumSortName = rawSong.albumSortName,

View file

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

View file

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

View file

@ -102,7 +102,12 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist. * @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId * @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 --- // --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles

View file

@ -18,6 +18,7 @@
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
@ -25,6 +26,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 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
@ -60,7 +63,10 @@ interface TagWorker {
class TagWorkerFactoryImpl class TagWorkerFactoryImpl
@Inject @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 = override fun create(rawSong: RawSong): TagWorker =
// Note that we do not leverage future callbacks. This is because errors in the // 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 // (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( MetadataRetriever.retrieveMetadata(
mediaSourceFactory, mediaSourceFactory,
MediaItem.fromUri( MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
coverExtractor)
} }
private class TagWorkerImpl( private class TagWorkerImpl(
private val rawSong: RawSong, private val rawSong: RawSong,
private val future: Future<TrackGroupArray> private val future: Future<TrackGroupArray>,
private val coverExtractor: CoverExtractor
) : TagWorker { ) : TagWorker {
override fun poll(): RawSong? { override fun poll(): RawSong? {
if (!future.isDone) { if (!future.isDone) {
@ -98,6 +106,11 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) 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 // 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.
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS // 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) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(false) .setIsBrowsable(false)
.setArtworkUri(album.coverUri.mediaStore) .setArtworkUri(album.cover.single.mediaStoreUri)
.setExtras( .setExtras(
Bundle().apply { Bundle().apply {
putString("uid", mediaSessionUID.toString()) putString("uid", mediaSessionUID.toString())
@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(coverUri.mediaStore) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setGenre(genres.resolveNames(context)) .setGenre(genres.resolveNames(context))
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover?.single?.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.user package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
@ -46,6 +47,8 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)" 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]. * Clone the data in this instance to a new [PlaylistImpl] with the given [name].
* *

View file

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

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.playback.service package org.oxycblt.auxio.playback.service
import android.content.Context
import android.os.Bundle
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@ -31,6 +33,7 @@ import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
import java.lang.Exception import java.lang.Exception
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
@ -60,6 +63,7 @@ import org.oxycblt.auxio.util.logE
* @author Alexander Capehart * @author Alexander Capehart
*/ */
class MediaSessionPlayer( class MediaSessionPlayer(
private val context: Context,
player: Player, player: Player,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val commandFactory: PlaybackCommand.Factory, private val commandFactory: PlaybackCommand.Factory,
@ -86,6 +90,20 @@ class MediaSessionPlayer(
setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) 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( override fun setMediaItems(
mediaItems: MutableList<MediaItem>, mediaItems: MutableList<MediaItem>,
startIndex: Int, startIndex: Int,

View file

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

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * 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.media.AudioManager
import android.os.Bundle import android.os.Bundle
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.session.CommandButton import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands import androidx.media3.session.SessionCommands
import dagger.hilt.android.qualifiers.ApplicationContext 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.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager 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.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetComponent
@ -102,6 +105,13 @@ constructor(
.setDisplayName(context.getString(R.string.desc_change_repeat)) .setDisplayName(context.getString(R.string.desc_change_repeat))
.setSessionCommand( .setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
.setEnabled(true)
.setExtras(
Bundle().apply {
putInt(
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX,
0)
})
.build()) .build())
} }
ActionMode.SHUFFLE -> { ActionMode.SHUFFLE -> {
@ -113,16 +123,56 @@ constructor(
.setDisplayName(context.getString(R.string.lbl_shuffle)) .setDisplayName(context.getString(R.string.lbl_shuffle))
.setSessionCommand( .setSessionCommand(
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
.setEnabled(true)
.build()) .build())
} }
else -> {} 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( actions.add(
CommandButton.Builder() CommandButton.Builder()
.setIconResId(R.drawable.ic_close_24) .setIconResId(R.drawable.ic_close_24)
.setDisplayName(context.getString(R.string.desc_exit)) .setDisplayName(context.getString(R.string.desc_exit))
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
.setEnabled(true)
.build()) .build())
return actions return actions
@ -133,6 +183,11 @@ constructor(
callback?.onCustomLayoutChanged(createCustomLayout()) callback?.onCustomLayoutChanged(createCustomLayout())
} }
override fun onProgressionChanged(progression: Progression) {
super.onProgressionChanged(progression)
callback?.onCustomLayoutChanged(createCustomLayout())
}
override fun onRepeatModeChanged(repeatMode: RepeatMode) { override fun onRepeatModeChanged(repeatMode: RepeatMode) {
super.onRepeatModeChanged(repeatMode) super.onRepeatModeChanged(repeatMode)
callback?.onCustomLayoutChanged(createCustomLayout()) callback?.onCustomLayoutChanged(createCustomLayout())

2
media

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