mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
ui: use the album grid for artist icons too
Load up to four thumbnail covers per visible artist row on demand and render the same 1-4 layout inside the circular artist icon. Lists stay on the expensive per-row query for now.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
internal suspend fun loadArtistArtCovers(
|
||||
library: LibraryRepository,
|
||||
source: SourceRepository,
|
||||
artistId: String,
|
||||
): List<CoverArtRef> {
|
||||
val sourceId = library.activeSourceId.first() ?: return emptyList()
|
||||
val own = library.artistOwnCoverArt(sourceId, artistId)
|
||||
val ids =
|
||||
if (own != null) {
|
||||
listOf(own)
|
||||
} else {
|
||||
library.artistFallbackCoverArts(sourceId, artistId)
|
||||
}
|
||||
return ids.take(4).mapNotNull { source.coverArt(it, thumbnail = true) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistIcon(
|
||||
artist: Artist,
|
||||
fallback: CoverArtRef?,
|
||||
loadCovers: suspend (String) -> List<CoverArtRef>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val covers by produceState(initialValue = emptyList<CoverArtRef>(), artist.id) {
|
||||
value = loadCovers(artist.id)
|
||||
}
|
||||
if (covers.size >= 2) {
|
||||
ArtistArtGrid(covers = covers, name = artist.name, modifier = modifier)
|
||||
} else {
|
||||
CoverArt(
|
||||
ref = covers.firstOrNull() ?: fallback,
|
||||
name = artist.name,
|
||||
showPlaceholder = covers.isEmpty() && fallback == null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistArtGrid(
|
||||
covers: List<CoverArtRef>,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
singleThumbnail: CoverArtRef? = null,
|
||||
) {
|
||||
when {
|
||||
covers.size >= 4 -> {
|
||||
Column(modifier) {
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.drop(2).take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 3 -> {
|
||||
Box(modifier) {
|
||||
covers.forEachIndexed { index, ref ->
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = name,
|
||||
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 2 -> {
|
||||
Row(modifier) {
|
||||
covers.forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 1 -> {
|
||||
CoverArt(
|
||||
ref = covers.first(),
|
||||
name = name,
|
||||
thumbnailRef = singleThumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class YRegionShape(
|
||||
private val region: Int,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val left = rayPoint(size, 210.0)
|
||||
val right = rayPoint(size, 330.0)
|
||||
val bottom = Offset(size.width / 2f, size.height)
|
||||
val path = Path()
|
||||
when (region) {
|
||||
0 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(left.x, left.y)
|
||||
addClockwise(path, size, left, right)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(bottom.x, bottom.y)
|
||||
addClockwise(path, size, bottom, left)
|
||||
}
|
||||
|
||||
else -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(right.x, right.y)
|
||||
addClockwise(path, size, right, bottom)
|
||||
}
|
||||
}
|
||||
path.lineTo(center.x, center.y)
|
||||
path.close()
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rayPoint(
|
||||
size: Size,
|
||||
angleDegrees: Double,
|
||||
): Offset {
|
||||
val cx = size.width / 2.0
|
||||
val cy = size.height / 2.0
|
||||
val radians = angleDegrees * PI / 180.0
|
||||
val dx = cos(radians)
|
||||
val dy = sin(radians)
|
||||
val tx =
|
||||
when {
|
||||
dx > 0 -> (size.width - cx) / dx
|
||||
dx < 0 -> -cx / dx
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val ty =
|
||||
when {
|
||||
dy > 0 -> (size.height - cy) / dy
|
||||
dy < 0 -> -cy / dy
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val t = min(tx, ty)
|
||||
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||
}
|
||||
|
||||
private fun addClockwise(
|
||||
path: Path,
|
||||
size: Size,
|
||||
from: Offset,
|
||||
to: Offset,
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val corners =
|
||||
listOf(
|
||||
Offset(0f, 0f),
|
||||
Offset(w, 0f),
|
||||
Offset(w, h),
|
||||
Offset(0f, h),
|
||||
)
|
||||
val fromParam = perimeterParam(from, size)
|
||||
val toParam = perimeterParam(to, size)
|
||||
if (fromParam <= toParam) {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
} else {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
corners
|
||||
.filter { perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
}
|
||||
path.lineTo(to.x, to.y)
|
||||
}
|
||||
|
||||
private fun perimeterParam(
|
||||
point: Offset,
|
||||
size: Size,
|
||||
): Float {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
return when {
|
||||
point.y <= 0f -> point.x
|
||||
point.x >= w -> w + point.y
|
||||
point.y >= h -> w + h + (w - point.x)
|
||||
else -> 2f * w + h + (h - point.y)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -108,6 +109,7 @@ fun HomeListRoute(
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@@ -133,6 +135,7 @@ fun HomeListScreen(
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -192,6 +195,7 @@ fun HomeListScreen(
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -246,6 +250,7 @@ fun HomeListScreen(
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -444,6 +449,7 @@ internal fun AlbumListRow(
|
||||
private fun ArtistsList(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
info: (Artist) -> String?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
@@ -463,6 +469,7 @@ private fun ArtistsList(
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
info = info(artist),
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
@@ -479,6 +486,7 @@ internal fun ArtistListRow(
|
||||
info: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -488,9 +496,10 @@ internal fun ArtistListRow(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = coverArt(artist.coverArt, true),
|
||||
loadCovers = artistCovers,
|
||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -156,6 +157,8 @@ class HomeListViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
|
||||
fun play(song: Song) {
|
||||
val genre = request.genre
|
||||
when {
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
@@ -137,6 +138,7 @@ fun HomeRoute(
|
||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -152,6 +154,7 @@ fun HomeRoute(
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -173,6 +176,7 @@ fun HomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -215,6 +219,7 @@ fun HomeScreen(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
artistCovers,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -249,6 +254,7 @@ fun HomeScreen(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
artistCovers,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -296,6 +302,7 @@ fun OfflineHomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -312,7 +319,7 @@ fun OfflineHomeScreen(
|
||||
) {
|
||||
homeTitle()
|
||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedArtistRow(artists, coverArt, artistCovers, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||
songList(
|
||||
key = "downloaded-songs",
|
||||
@@ -365,6 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
||||
private fun LazyListScope.downloadedArtistRow(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
@@ -386,6 +394,7 @@ private fun LazyListScope.downloadedArtistRow(
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
artistCovers = artistCovers,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
)
|
||||
@@ -607,6 +616,7 @@ private fun LazyListScope.artistRow(
|
||||
section: HomeSection,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
@@ -629,6 +639,7 @@ private fun LazyListScope.artistRow(
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
artistCovers = artistCovers,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
)
|
||||
@@ -780,6 +791,7 @@ private fun AlbumTile(
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
@@ -787,9 +799,10 @@ private fun ArtistTile(
|
||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = ref,
|
||||
loadCovers = artistCovers,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.subtracks.data.repo.rediscoverCutoff
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -127,6 +128,8 @@ class HomeViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
|
||||
fun playStarred(song: Song) {
|
||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArtGrid
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
@@ -402,13 +403,22 @@ fun ArtistDetailScreen(
|
||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
ArtistHeroArt(
|
||||
covers = artCovers,
|
||||
fallback = art,
|
||||
fallbackThumbnail = artThumbnail,
|
||||
name = displayName,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
if (artCovers.isNotEmpty()) {
|
||||
ArtistArtGrid(
|
||||
covers = artCovers,
|
||||
name = displayName,
|
||||
singleThumbnail = artThumbnail,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
} else {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = displayName,
|
||||
thumbnailRef = artThumbnail,
|
||||
showPlaceholder = art == null,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
}
|
||||
if (nameScrimAlpha > 0f) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -833,181 +843,6 @@ internal fun splitBioCollapsed(
|
||||
return BioLines(lines, more = true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeroArt(
|
||||
covers: List<CoverArtRef>,
|
||||
fallback: CoverArtRef?,
|
||||
fallbackThumbnail: CoverArtRef?,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
covers.size >= 4 -> {
|
||||
Column(modifier) {
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.drop(2).take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 3 -> {
|
||||
Box(modifier) {
|
||||
covers.forEachIndexed { index, ref ->
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = name,
|
||||
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 2 -> {
|
||||
Row(modifier) {
|
||||
covers.forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 1 -> {
|
||||
CoverArt(
|
||||
ref = covers.first(),
|
||||
name = name,
|
||||
thumbnailRef = fallbackThumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(
|
||||
ref = fallback,
|
||||
name = name,
|
||||
thumbnailRef = fallbackThumbnail,
|
||||
showPlaceholder = fallback == null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class YRegionShape(
|
||||
private val region: Int,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val left = rayPoint(size, 210.0)
|
||||
val right = rayPoint(size, 330.0)
|
||||
val bottom = Offset(size.width / 2f, size.height)
|
||||
val path = Path()
|
||||
when (region) {
|
||||
0 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(left.x, left.y)
|
||||
addClockwise(path, size, left, right)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(bottom.x, bottom.y)
|
||||
addClockwise(path, size, bottom, left)
|
||||
}
|
||||
|
||||
else -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(right.x, right.y)
|
||||
addClockwise(path, size, right, bottom)
|
||||
}
|
||||
}
|
||||
path.lineTo(center.x, center.y)
|
||||
path.close()
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rayPoint(
|
||||
size: Size,
|
||||
angleDegrees: Double,
|
||||
): Offset {
|
||||
val cx = size.width / 2.0
|
||||
val cy = size.height / 2.0
|
||||
val radians = angleDegrees * PI / 180.0
|
||||
val dx = cos(radians)
|
||||
val dy = sin(radians)
|
||||
val tx =
|
||||
when {
|
||||
dx > 0 -> (size.width - cx) / dx
|
||||
dx < 0 -> -cx / dx
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val ty =
|
||||
when {
|
||||
dy > 0 -> (size.height - cy) / dy
|
||||
dy < 0 -> -cy / dy
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val t = min(tx, ty)
|
||||
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||
}
|
||||
|
||||
private fun addClockwise(
|
||||
path: Path,
|
||||
size: Size,
|
||||
from: Offset,
|
||||
to: Offset,
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val corners =
|
||||
listOf(
|
||||
Offset(0f, 0f),
|
||||
Offset(w, 0f),
|
||||
Offset(w, h),
|
||||
Offset(0f, h),
|
||||
)
|
||||
val fromParam = perimeterParam(from, size)
|
||||
val toParam = perimeterParam(to, size)
|
||||
if (fromParam <= toParam) {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
} else {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
corners
|
||||
.filter { perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
}
|
||||
path.lineTo(to.x, to.y)
|
||||
}
|
||||
|
||||
private fun perimeterParam(
|
||||
point: Offset,
|
||||
size: Size,
|
||||
): Float {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
return when {
|
||||
point.y <= 0f -> point.x
|
||||
point.x >= w -> w + point.y
|
||||
point.y >= h -> w + h + (w - point.x)
|
||||
else -> 2f * w + h + (h - point.y)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSectionHeader(
|
||||
text: String,
|
||||
|
||||
@@ -29,7 +29,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
@@ -55,6 +55,7 @@ fun ArtistsContent(
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -104,9 +105,10 @@ fun ArtistsContent(
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
fallback = coverArt(artist.coverArt, true),
|
||||
loadCovers = artistCovers,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -308,6 +308,7 @@ fun LibraryRoute(
|
||||
artists = viewModel.artists,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -396,6 +397,7 @@ fun LibraryScreen(
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
onOpenSearch: () -> Unit = {},
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
@@ -539,6 +541,7 @@ fun LibraryScreen(
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
artistCovers = artistCovers,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -211,6 +212,8 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fun SearchRoute(
|
||||
if (next != 0) filterMask = next
|
||||
},
|
||||
coverArt = viewModel::coverArt,
|
||||
artistCovers = viewModel::artistArtCovers,
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = {
|
||||
@@ -146,6 +147,7 @@ fun SearchScreen(
|
||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistCovers: suspend (String) -> List<CoverArtRef> = { emptyList() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -215,7 +217,7 @@ fun SearchScreen(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, artistCovers, onArtistClick, onLongClick)
|
||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||
}
|
||||
@@ -309,6 +311,7 @@ private fun LazyListScope.artistResults(
|
||||
artists: List<Artist>,
|
||||
header: String,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistCovers: suspend (String) -> List<CoverArtRef>,
|
||||
onClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
) {
|
||||
@@ -318,6 +321,7 @@ private fun LazyListScope.artistResults(
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
artistCovers = artistCovers,
|
||||
info = null,
|
||||
onClick = { onClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.loadArtistArtCovers
|
||||
import com.subtracks.ui.home.playSongInContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -74,6 +75,8 @@ class SearchViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
suspend fun artistArtCovers(artistId: String): List<CoverArtRef> = loadArtistArtCovers(libraryRepository, sourceRepository, artistId)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
@@ -613,6 +613,13 @@ class AppScreenshotTest {
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
artistCovers = { id ->
|
||||
Fixtures.albums
|
||||
.filter { it.artistId == id }
|
||||
.take(4)
|
||||
.mapNotNull { it.coverArt }
|
||||
.map { CoverArtRef(it, "test:$it") }
|
||||
},
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
|
||||
Reference in New Issue
Block a user