Merge branch 'media3' into dev
This commit is contained in:
commit
b4cf6a9563
22 changed files with 365 additions and 141 deletions
|
@ -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(
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
|
|
59
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
59
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal 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)
|
||||||
|
}
|
|
@ -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())
|
||||||
|
|
|
@ -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 {
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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].
|
||||||
*
|
*
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -100,7 +100,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleTaskRemoved() {
|
fun handleTaskRemoved() {
|
||||||
if (playbackManager.progression.isPlaying) {
|
if (!playbackManager.progression.isPlaying) {
|
||||||
playbackManager.endSession()
|
playbackManager.endSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
2
media
|
@ -1 +1 @@
|
||||||
Subproject commit bfa4c10f773bb9336d9c7dade490463318b12ab6
|
Subproject commit 6c77cfa13c83bf2ae5188603d2c9a51ec4cb3ac3
|
Loading…
Reference in a new issue