Refactor coil

Completely refactor coil and how its used so that it centers around data objects instead of a hodgepodge of URIs and Song data.
This commit is contained in:
OxygenCobalt 2021-02-12 22:28:41 -07:00
parent 54f9ceca90
commit e0485ebad9
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
23 changed files with 211 additions and 461 deletions

View file

@ -0,0 +1,81 @@
package org.oxycblt.auxio.coil
import android.content.Context
import android.media.MediaMetadataRetriever
import coil.bitmap.BitmapPool
import coil.decode.DataSource
import coil.decode.Options
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import okio.buffer
import okio.source
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.settings.SettingsManager
import java.io.ByteArrayInputStream
import java.io.InputStream
/**
* Fetcher that returns the album art for a given [Album]. Handles settings on whether to use
* quality covers or not.
*/
class AlbumArtFetcher(private val context: Context) : Fetcher<Album> {
private val settingsManager = SettingsManager.getInstance()
override suspend fun fetch(
pool: BitmapPool,
data: Album,
size: Size,
options: Options
): FetchResult {
return if (settingsManager.useQualityCovers) {
loadQualityCovers(data)
} else {
loadMediaStoreCovers(data)
}
}
private fun loadMediaStoreCovers(data: Album): SourceResult {
val stream: InputStream? = context.contentResolver.openInputStream(data.coverUri)
stream?.let { stm ->
return SourceResult(
source = stm.source().buffer(),
mimeType = context.contentResolver.getType(data.coverUri),
dataSource = DataSource.DISK
)
}
error("No cover art for album ${data.name}")
}
private fun loadQualityCovers(data: Album): SourceResult {
val extractor = MediaMetadataRetriever()
extractor.use { ext ->
val songUri = data.songs[0].id.toURI()
ext.setDataSource(context, songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [a.k.a there is no embedded cover], than just ignore it and move on
ext.embeddedPicture?.let { coverBytes ->
val stream = ByteArrayInputStream(coverBytes)
return SourceResult(
source = stream.source().buffer(),
mimeType = context.contentResolver.getType(songUri),
dataSource = DataSource.DISK
)
}
}
// If we are here, the extractor likely failed so instead attempt to return the compressed
// cover instead.
return loadMediaStoreCovers(data)
}
override fun key(data: Album) = data.id.toString()
}

View file

@ -2,7 +2,6 @@ package org.oxycblt.auxio.coil
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.widget.ImageView import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.databinding.BindingAdapter import androidx.databinding.BindingAdapter
@ -15,185 +14,111 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
private val ignoreCovers: Boolean get() = !SettingsManager.getInstance().showCovers
/** /**
* Get a bitmap for a song. onDone will be called when the bitmap is loaded. * Get a bitmap for a song. onDone will be called when the bitmap is loaded.
* **Do not use this on the UI elements, instead use the Binding Adapters.** * **This not meant for UIs, instead use the Binding Adapters.**
* @param context [Context] required * @param context [Context] required
* @param song Song to load the cover for * @param song Song to load the cover for
* @param onDone What to do with the bitmap when the loading is finished. Bitmap will be null if loading failed/shouldn't occur. * @param onDone What to do with the bitmap when the loading is finished. Bitmap will be null if loading failed/shouldn't occur.
*/ */
fun getBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) { fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
if (!SettingsManager.getInstance().showCovers) { if (ignoreCovers) {
onDone(null) onDone(null)
return return
} }
val request = ImageRequest.Builder(context) Coil.imageLoader(context).enqueue(
.doCoverSetup(context, song) ImageRequest.Builder(context)
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) }) .data(song.album)
.fetcher(AlbumArtFetcher(context))
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
.build() .build()
)
Coil.imageLoader(context).enqueue(request)
} }
// --- BINDING ADAPTERS --- // --- BINDING ADAPTERS ---
/** /**
* Bind the cover art for a song. * Bind the album art for a [Song].
*/ */
@BindingAdapter("coverArt") @BindingAdapter("albumArt")
fun ImageView.bindCoverArt(song: Song) { fun ImageView.bindAlbumArt(song: Song) {
if (!SettingsManager.getInstance().showCovers) { if (ignoreCovers) {
setImageResource(R.drawable.ic_song) setImageResource(R.drawable.ic_song)
return return
} }
val request = newRequest() Coil.imageLoader(context).enqueue(
.doCoverSetup(context, song) ImageRequest.Builder(context)
.target(this)
.data(song.album)
.fetcher(AlbumArtFetcher(context))
.error(R.drawable.ic_song) .error(R.drawable.ic_song)
.build() .build()
)
Coil.imageLoader(context).enqueue(request)
} }
/** /**
* Bind the cover art for an album * Bind the album art for an [Album].
*/ */
@BindingAdapter("coverArt") @BindingAdapter("albumArt")
fun ImageView.bindCoverArt(album: Album) { fun ImageView.bindAlbumArt(album: Album) {
if (!SettingsManager.getInstance().showCovers) { if (ignoreCovers) {
setImageResource(R.drawable.ic_album) setImageResource(R.drawable.ic_album)
return return
} }
val request = newRequest() Coil.imageLoader(context).enqueue(
.doCoverSetup(context, album) ImageRequest.Builder(context)
.target(this)
.data(album)
.fetcher(AlbumArtFetcher(context))
.error(R.drawable.ic_album) .error(R.drawable.ic_album)
.build() .build()
)
Coil.imageLoader(context).enqueue(request)
} }
/** /**
* Bind the artist image for an artist. * Bind the image for an [Artist]
*/ */
@BindingAdapter("artistImage") @BindingAdapter("artistImage")
fun ImageView.bindArtistImage(artist: Artist) { fun ImageView.bindArtistImage(artist: Artist) {
if (!SettingsManager.getInstance().showCovers) { if (ignoreCovers) {
setImageResource(R.drawable.ic_artist) setImageResource(R.drawable.ic_artist)
return return
} }
val request: ImageRequest Coil.imageLoader(context).enqueue(
ImageRequest.Builder(context)
// If there is more than one album, then create a mosaic of them. .target(this)
if (artist.albums.size >= 4) { .data(artist)
val uris = mutableListOf<Uri>() .fetcher(MosaicFetcher(context))
for (i in 0..3) {
uris.add(artist.albums[i].coverUri)
}
val fetcher = MosaicFetcher(context)
request = newRequest()
.data(uris)
.fetcher(fetcher)
.error(R.drawable.ic_artist) .error(R.drawable.ic_artist)
.build() .build()
} else { )
// Otherwise, just get the first cover and use that
// If the artist doesn't have any albums [Which happens], then don't even bother with that.
if (artist.albums.isNotEmpty()) {
request = newRequest()
.doCoverSetup(context, artist.albums[0])
.error(R.drawable.ic_artist)
.build()
} else {
setImageResource(R.drawable.ic_artist)
return
}
}
Coil.imageLoader(context).enqueue(request)
} }
/** /**
* Bind the genre image for a genre. * Bind the image for a [Genre]
*/ */
@BindingAdapter("genreImage") @BindingAdapter("genreImage")
fun ImageView.bindGenreImage(genre: Genre) { fun ImageView.bindGenreImage(genre: Genre) {
if (!SettingsManager.getInstance().showCovers) { if (ignoreCovers) {
setImageResource(R.drawable.ic_genre) setImageResource(R.drawable.ic_genre)
return return
} }
val request: ImageRequest Coil.imageLoader(context).enqueue(
val genreCovers = mutableListOf<Uri>() ImageRequest.Builder(context)
.target(this)
// Group the genre's songs by their album's cover and add them .data(genre)
genre.songs.groupBy { it.album.coverUri }.forEach { genreCovers.add(it.key) } .fetcher(MosaicFetcher(context))
if (genreCovers.size >= 4) {
val fetcher = MosaicFetcher(context)
request = newRequest()
.data(genreCovers.slice(0..3))
.fetcher(fetcher)
.error(R.drawable.ic_genre) .error(R.drawable.ic_genre)
.build() .build()
} else { )
if (genreCovers.isNotEmpty()) {
request = newRequest()
.doCoverSetup(context, genre.songs[0])
.error(R.drawable.ic_genre)
.build()
} else {
setImageResource(R.drawable.ic_genre)
return
}
}
Coil.imageLoader(context).enqueue(request)
}
/**
* Determine if a high quality or low-quality cover needs to be loaded for a specific [Album]
* @return The same builder that this is applied to
*/
private fun ImageRequest.Builder.doCoverSetup(context: Context, data: Album): ImageRequest.Builder {
if (SettingsManager.getInstance().useQualityCovers) {
fetcher(QualityCoverFetcher(context))
data(data.songs[0])
} else {
data(data.coverUri)
}
return this
}
/**
* Determine if a high quality or low-quality cover needs to be loaded for a specific [Song]
* @return The same builder that this is applied to
*/
private fun ImageRequest.Builder.doCoverSetup(context: Context, data: Song): ImageRequest.Builder {
if (SettingsManager.getInstance().useQualityCovers) {
fetcher(QualityCoverFetcher(context))
data(data)
} else {
data(data.album.coverUri)
}
return this
}
/**
* Get the base request used by the above functions
* @return The base request
*/
private fun ImageView.newRequest(): ImageRequest.Builder {
return ImageRequest.Builder(context).target(this)
} }

View file

@ -16,24 +16,43 @@ import coil.fetch.SourceResult
import coil.size.Size import coil.size.Size
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Parent
import java.io.Closeable import java.io.Closeable
import java.io.InputStream import java.io.InputStream
/** /**
* A [Fetcher] that takes multiple cover uris and turns them into a 2x2 mosaic image. * A [Fetcher] that takes an [Artist] or [Genre] and returns a mosaic of its albums.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> { class MosaicFetcher(private val context: Context) : Fetcher<Parent> {
override suspend fun fetch( override suspend fun fetch(
pool: BitmapPool, pool: BitmapPool,
data: List<Uri>, data: Parent,
size: Size, size: Size,
options: Options options: Options
): FetchResult { ): FetchResult {
// Get the URIs for either a genre or artist
val uris = mutableListOf<Uri>()
when (data) {
is Artist -> data.albums.forEachIndexed { index, album ->
if (index < 4) { uris.add(album.coverUri) }
}
is Genre -> data.songs.groupBy { it.album.coverUri }.keys.forEachIndexed { index, uri ->
if (index < 4) { uris.add(uri) }
}
else -> {}
}
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
// Load MediaStore streams // Load MediaStore streams
data.forEach { uris.forEach {
val stream: InputStream? = context.contentResolver.openInputStream(it) val stream: InputStream? = context.contentResolver.openInputStream(it)
if (stream != null) { if (stream != null) {
@ -44,26 +63,35 @@ class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> {
// If so many streams failed that there's not enough images to make a mosaic, then // If so many streams failed that there's not enough images to make a mosaic, then
// just return the first cover image. // just return the first cover image.
if (streams.size < 4) { if (streams.size < 4) {
streams.forEach { it.close() } // Dont even bother if ALL the streams have failed.
check(streams.isNotEmpty()) { "All streams have failed. " }
return if (streams.isNotEmpty()) { return SourceResult(
SourceResult(
source = streams[0].source().buffer(), source = streams[0].source().buffer(),
mimeType = context.contentResolver.getType(data[0]), mimeType = context.contentResolver.getType(uris[0]),
dataSource = DataSource.DISK dataSource = DataSource.DISK
) )
} else {
error("All streams failed. Not bothering.")
}
} }
// Create the mosaic, code adapted from Phonograph. val bitmap = drawMosaic(streams)
// https://github.com/kabouzeid/Phonograph
val finalBitmap = Bitmap.createBitmap( return DrawableResult(
drawable = bitmap.toDrawable(context.resources),
isSampled = false,
dataSource = DataSource.DISK
)
}
/**
* Create the mosaic, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph
*/
private fun drawMosaic(streams: List<InputStream>): Bitmap {
val mosaicBitmap = Bitmap.createBitmap(
MOSAIC_BITMAP_SIZE, MOSAIC_BITMAP_SIZE, Bitmap.Config.RGB_565 MOSAIC_BITMAP_SIZE, MOSAIC_BITMAP_SIZE, Bitmap.Config.RGB_565
) )
val canvas = Canvas(finalBitmap) val canvas = Canvas(mosaicBitmap)
var x = 0 var x = 0
var y = 0 var y = 0
@ -90,11 +118,7 @@ class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> {
} }
} }
return DrawableResult( return mosaicBitmap
drawable = finalBitmap.toDrawable(context.resources),
isSampled = false,
dataSource = DataSource.DISK
)
} }
/** /**
@ -107,7 +131,8 @@ class MosaicFetcher(private val context: Context) : Fetcher<List<Uri>> {
} }
} }
override fun key(data: List<Uri>): String = data.toString() override fun key(data: Parent): String = data.id.toString()
override fun handles(data: Parent) = data !is Album // Albums are not used here
companion object { companion object {
private const val MOSAIC_BITMAP_SIZE = 512 private const val MOSAIC_BITMAP_SIZE = 512

View file

@ -1,77 +0,0 @@
package org.oxycblt.auxio.coil
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import coil.bitmap.BitmapPool
import coil.decode.DataSource
import coil.decode.Options
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import okio.buffer
import okio.source
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI
import java.io.ByteArrayInputStream
import java.io.InputStream
/**
* A [Fetcher] for fetching high-quality embedded covers instead of the compressed covers, albeit
* at the cost of load time & memory usage.
*/
class QualityCoverFetcher(private val context: Context) : Fetcher<Song> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun fetch(
pool: BitmapPool,
data: Song,
size: Size,
options: Options
): FetchResult {
val extractor = MediaMetadataRetriever()
val stream: InputStream?
val uri: Uri
extractor.use { ext ->
ext.setDataSource(context, data.id.toURI())
val cover = ext.embeddedPicture
stream = if (cover != null) {
uri = data.id.toURI()
ByteArrayInputStream(cover)
} else {
// Fallback to the compressed cover if the cover loading failed.
uri = data.album.coverUri
// Blocking call, but coil is on a background thread so it doesn't matter
context.contentResolver.openInputStream(data.album.coverUri)
}
stream?.use {
return SourceResult(
source = it.source().buffer(),
mimeType = context.contentResolver.getType(uri),
dataSource = DataSource.DISK
)
}
}
// If we are here, the extractor likely failed so instead attempt to return the compressed
// cover instead.
context.contentResolver.openInputStream(data.album.coverUri)?.use {
return SourceResult(
source = it.source().buffer(),
mimeType = context.contentResolver.getType(uri),
dataSource = DataSource.DISK
)
}
// If even that failed, then error out entirely.
error("Likely no bitmap for this song/album.")
}
// Group bitmaps by their album so that caching is more efficent
override fun key(data: Song): String = data.album.id.toString()
}

View file

@ -13,7 +13,7 @@ import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.MainActivity
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.getBitmap import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.logE import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -110,7 +110,7 @@ fun NotificationCompat.Builder.setMetadata(
if (colorize) { if (colorize) {
// getBitmap() is concurrent, so only call back to the object calling this function when // getBitmap() is concurrent, so only call back to the object calling this function when
// the loading is over. // the loading is over.
getBitmap(context, song) { loadBitmap(context, song) {
setLargeIcon(it) setLargeIcon(it)
onDone() onDone()

View file

@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.coil.getBitmap import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.logD import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toURI import org.oxycblt.auxio.music.toURI
@ -392,7 +392,7 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
getBitmap(this, song) { loadBitmap(this, song) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it) builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
mediaSession.setMetadata(builder.build()) mediaSession.setMetadata(builder.build())
} }

View file

@ -32,7 +32,7 @@
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
android:layout_marginBottom="@dimen/margin_small" android:layout_marginBottom="@dimen/margin_small"
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1" app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -47,7 +47,7 @@
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1" app:layout_constraintDimensionRatio="1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -32,7 +32,7 @@
android:contentDescription="@{@string/description_album_cover(album.name)}" android:contentDescription="@{@string/description_album_cover(album.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{album}" app:albumArt="@{album}"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_album" /> tools:src="@drawable/ic_album" />

View file

@ -1,208 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackFragment">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
<variable
name="playbackModel"
type="org.oxycblt.auxio.playback.PlaybackViewModel" />
<variable
name="detailModel"
type="org.oxycblt.auxio.detail.DetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playback_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:fitsSystemWindows="true">
<androidx.appcompat.widget.Toolbar
android:id="@+id/playback_toolbar"
style="@style/Toolbar.Style.Icon"
android:elevation="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback"
app:navigationIcon="@drawable/ic_down"
app:title="@string/label_playback" />
<ImageView
android:id="@+id/playback_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="@dimen/margin_huge"
android:contentDescription="@{@string/description_album_cover(song.name)}"
android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds"
app:coverArt="@{song}"
app:layout_constraintBottom_toTopOf="@+id/playback_song"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_toolbar"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/playback_song"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_huge"
android:layout_marginEnd="@dimen/margin_huge"
android:ellipsize="marquee"
android:fontFamily="@font/inter_semibold"
android:marqueeRepeatLimit="marquee_forever"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song)}"
android:singleLine="true"
android:text="@{song.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/playback_artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/playback_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_huge"
android:layout_marginEnd="@dimen/margin_huge"
android:ellipsize="end"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
android:singleLine="true"
android:text="@{song.album.artist.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Artist Name" />
<TextView
android:id="@+id/playback_album"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_huge"
android:layout_marginEnd="@dimen/margin_huge"
android:layout_marginBottom="@dimen/margin_medium"
android:ellipsize="end"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album)}"
android:singleLine="true"
android:text="@{song.album.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Album Name" />
<SeekBar
android:id="@+id/playback_seek_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:paddingStart="@dimen/margin_huge"
android:paddingEnd="@dimen/margin_huge"
android:progressBackgroundTint="?android:attr/colorControlNormal"
android:progressTint="?attr/colorPrimary"
android:splitTrack="false"
android:thumbOffset="@dimen/offset_thumb"
android:thumbTint="?attr/colorPrimary"
app:layout_constraintBottom_toTopOf="@+id/playback_duration_current"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:progress="70" />
<TextView
android:id="@+id/playback_duration_current"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_huge"
android:layout_marginBottom="@dimen/margin_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_play_pause"
app:layout_constraintStart_toStartOf="parent"
tools:text="11:38" />
<TextView
android:id="@+id/playback_song_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_huge"
android:layout_marginBottom="@dimen/margin_medium"
android:text="@{song.formattedDuration}"
app:layout_constraintBottom_toTopOf="@+id/playback_play_pause"
app:layout_constraintEnd_toEndOf="parent"
tools:text="16:16" />
<ImageButton
android:id="@+id/playback_loop"
style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large"
android:contentDescription="@string/description_change_loop"
android:onClick="@{() -> playbackModel.incrementLoopStatus()}"
android:src="@drawable/ic_loop"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintEnd_toStartOf="@+id/playback_skip_prev"
app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" />
<ImageButton
android:id="@+id/playback_skip_prev"
style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large"
android:contentDescription="@string/description_skip_prev"
android:onClick="@{() -> playbackModel.skipPrev()}"
android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton
android:id="@+id/playback_play_pause"
style="@style/PlayPause"
android:layout_marginBottom="@dimen/margin_large"
android:contentDescription="@string/description_play_pause"
android:onClick="@{() -> playbackModel.invertPlayingStatus()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/playback_song_duration"
app:layout_constraintStart_toStartOf="@+id/playback_duration_current"
tools:src="@drawable/ic_play_to_pause" />
<ImageButton
android:id="@+id/playback_skip_next"
style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:contentDescription="@string/description_skip_next"
android:onClick="@{() -> playbackModel.skipNext()}"
android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintStart_toEndOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton
android:id="@+id/playback_shuffle"
style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:contentDescription="@{playbackModel.isShuffling() ? @string/description_shuffle_off : @string/description_shuffle_on"
android:onClick="@{() -> playbackModel.invertShuffleStatus()}"
android:src="@drawable/ic_shuffle"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintStart_toEndOf="@+id/playback_skip_next"
app:layout_constraintTop_toTopOf="@+id/playback_skip_next" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -32,7 +32,7 @@
android:contentDescription="@{@string/description_album_cover(album.name)}" android:contentDescription="@{@string/description_album_cover(album.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{album}" app:albumArt="@{album}"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_album" /> tools:src="@drawable/ic_album" />

View file

@ -46,7 +46,7 @@
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1" app:layout_constraintDimensionRatio="1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View file

@ -42,7 +42,7 @@
android:layout_height="@dimen/size_cover_compact" android:layout_height="@dimen/size_cover_compact"
android:layout_margin="@dimen/margin_mid_small" android:layout_margin="@dimen/margin_mid_small"
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_progress" app:layout_constraintTop_toBottomOf="@+id/playback_progress"

View file

@ -45,7 +45,7 @@
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toTopOf="@+id/playback_song" app:layout_constraintBottom_toTopOf="@+id/playback_song"
app:layout_constraintDimensionRatio="1:1" app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View file

@ -18,7 +18,7 @@
android:layout_width="@dimen/size_cover_normal" android:layout_width="@dimen/size_cover_normal"
android:layout_height="@dimen/size_cover_normal" android:layout_height="@dimen/size_cover_normal"
android:contentDescription="@{@string/description_album_cover(album.name)}" android:contentDescription="@{@string/description_album_cover(album.name)}"
app:coverArt="@{album}" app:albumArt="@{album}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -31,7 +31,7 @@
android:contentDescription="@{@string/description_album_cover(album.name)}" android:contentDescription="@{@string/description_album_cover(album.name)}"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds" android:outlineProvider="bounds"
app:coverArt="@{album}" app:albumArt="@{album}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -18,7 +18,7 @@
android:layout_width="@dimen/size_cover_large" android:layout_width="@dimen/size_cover_large"
android:layout_height="@dimen/size_cover_large" android:layout_height="@dimen/size_cover_large"
android:contentDescription="@{@string/description_album_cover(album.name)}" android:contentDescription="@{@string/description_album_cover(album.name)}"
app:coverArt="@{album}" app:albumArt="@{album}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -18,7 +18,7 @@
android:layout_width="@dimen/size_cover_compact" android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact" android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -21,7 +21,7 @@
android:layout_width="@dimen/size_cover_compact" android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact" android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -18,7 +18,7 @@
android:layout_width="@dimen/size_cover_compact" android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact" android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}" android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}" app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -12,7 +12,7 @@
<dimen name="margin_medium">16dp</dimen> <dimen name="margin_medium">16dp</dimen>
<dimen name="margin_mid_large">24dp</dimen> <dimen name="margin_mid_large">24dp</dimen>
<dimen name="margin_large">32dp</dimen> <dimen name="margin_large">32dp</dimen>
<dimen name="margin_huge">48dp</dimen> <dimen name="margin_mid_huge">48dp</dimen>
<dimen name="margin_insane">128dp</dimen> <dimen name="margin_insane">128dp</dimen>
<!-- Height Namespace | Height for UI elements --> <!-- Height Namespace | Height for UI elements -->

View file

@ -8,7 +8,7 @@ These will likely be accepted as long as they do not cause too much harm to the
## New Customizations/Options ## New Customizations/Options
While I do like adding new behavior/UI customizations, these will be looked at more closely as certain additions can cause harm to the apps UI/UX while not providing alot of benefit. These tend to be accpeted however. While I do like adding new behavior/UI customizations, these will be looked at more closely as certain additions can cause harm to the apps UI/UX while not providing alot of benefit. These tend to be accepted however.
## Feature Addtions and UI Changes ## Feature Addtions and UI Changes

View file

@ -64,7 +64,7 @@ org.oxycblt.auxio # Main UI's and logging utilities
- `app:genreImage`: Binding Adapter that will load the genre image - `app:genreImage`: Binding Adapter that will load the genre image
- `getBitmap`: Function that will take a song and return a bitmap, this should not be used in anything UI related, that is what the binding adapters above are for. - `getBitmap`: Function that will take a song and return a bitmap, this should not be used in anything UI related, that is what the binding adapters above are for.
This should be enough to cover most use cases in Auxio. There are also fetchers for artist/genre images and higher quality covers, but these are not used outside of the module. This should be enough to cover most use cases in Auxio. There are also fetchers for artist/genre images and album covers, but these are not used outside of the module.
#### `.database` #### `.database`
@ -117,6 +117,10 @@ PlaybackStateManager───────────────────┘
Shared RecyclerView utilities, often for adapters and ViewHolders. Important ones to note are `DiffCallback`, which acts as a reusable differ callback based off of `BaseModel` for `ListAdapter`s, and the shared ViewHolders for each data type, such as `SongViewHolder` or `HeaderViewHolder`. Shared RecyclerView utilities, often for adapters and ViewHolders. Important ones to note are `DiffCallback`, which acts as a reusable differ callback based off of `BaseModel` for `ListAdapter`s, and the shared ViewHolders for each data type, such as `SongViewHolder` or `HeaderViewHolder`.
#### `.settings`
The settings system is primarily based off of `SettingsManager`, a wrapper around `SharedPreferences`. This allows settings to be read/written in a much simpler/safer manner and without a context being needed. The Settings UI is largely contained in `SettingsListFragment`, while the `.ui` sub-package contains UIs related to the settings UI, such as the About Dialog.
#### `.search` #### `.search`
Package for Auxio's search functionality, `SearchViewHolder` handles the data results and filtering while `SearchFragment`/`SearchAdapter` handles the display of the results and user input. Package for Auxio's search functionality, `SearchViewHolder` handles the data results and filtering while `SearchFragment`/`SearchAdapter` handles the display of the results and user input.
@ -130,4 +134,4 @@ Package for the songs UI, there is no data management here, only a user interfac
Shared User Interface utilities. This is primarily made up of convenience/extension functions relating to Views, Resources, Configurations, and Contexts. It also contains some dedicated utilities, such as: Shared User Interface utilities. This is primarily made up of convenience/extension functions relating to Views, Resources, Configurations, and Contexts. It also contains some dedicated utilities, such as:
- The Accent Management system - The Accent Management system
- `newMenu` and `ActionMenu`, which automates menu creation for most data types - `newMenu` and `ActionMenu`, which automates menu creation for most data types
- `memberBinding` and `MemberBinder`, which allows for viewbindings to be used as a member variable without memory leaks or nullability issues. - `memberBinding` and `MemberBinder`, which allows for ViewBindings to be used as a member variable without memory leaks or nullability issues.