Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6e45cbf
android: skeleton loading, cached artwork paths, Navic-style grids an…
claude Sep 24, 2026
3da4181
android: screenshot test for home, library, album and the player
claude Sep 24, 2026
83bcb46
android: stats skeleton, album carousels in search, no double bottom …
claude Sep 24, 2026
8823425
android: shell motion - shared-axis pushes, fade-through tabs, no sta…
claude Sep 24, 2026
1a43dd5
android: Metrolist-style morphing player sheet
claude Sep 24, 2026
b5779b5
android: scroll root lists to top when their tab is tapped again
claude Sep 24, 2026
7370b14
android: test playlist row keys and the album grid's column rule
claude Sep 24, 2026
e98a7fa
android: fit the full player on one screen; public mini-player gap
claude Sep 24, 2026
02b20ae
android: step the clock by hand while waiting for the offline snackbar
claude Sep 24, 2026
ce0ab4a
android: Apple Music-style queue list without tabs
claude Sep 24, 2026
d7f9cde
android: floating Navic-style mini player and an expressive full play…
claude Sep 24, 2026
79f926a
android: player artwork laid out at its visible size; tests click row…
claude Sep 24, 2026
e4c5b9b
android: queue swipe background only while swiping; recent queues cle…
claude Sep 24, 2026
1c9f28c
android: player tweaks - Connect in the header, sleep timer in More, …
claude Sep 24, 2026
75dbf78
android: rating moves to the top of the song menu; title block centre…
claude Sep 24, 2026
3001ebe
Merge origin/main into claude/loving-euler-m3kqyo
claude Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions android/app/src/main/java/app/hocket/ui/components/Artwork.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -24,13 +23,21 @@ import app.hocket.core.client.CoreClient
import app.hocket.ui.LocalCoreClient
import app.hocket.ui.theme.ArtworkColors
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import androidx.compose.runtime.LaunchedEffect
import java.io.File

/**
* Resolves `coverArt` through `Query.Artwork` at one of the fixed cache sizes and shows it with coil.
* While resolving, or when the core has no file (fake core, offline), a deterministic two-tone
* gradient derived from the id stands in, so grids never flash white and the fake core still looks
* like a library. Battery saver drops one size step (design: battery saver).
*
* Resolved paths are remembered in [ArtworkPathCache], so a row composed again (scrolling back, a
* pager page, the player hero) starts from the known path on its first frame and coil serves the
* bitmap from its memory cache without a placeholder flash. A first load crossfades in over the
* plain `surfaceContainerHigh` backdrop. When the core has no file (fake core, offline), a
* deterministic two-tone gradient derived from the id stands in, so the fake core still looks like
* a library. Battery saver drops one size step (design: battery saver).
*/
@Composable
fun Artwork(
Expand All @@ -43,22 +50,55 @@ fun Artwork(
) {
val batterySaver by client.batterySaver.collectAsStateWithLifecycleCompat()
val requested = if (batterySaver) smallerSize(size) else size
val path by produceState<String?>(initialValue = null, coverArt, requested) {
value = client.artworkPath(coverArt, requested)
val cacheKey = coverArt?.let { ArtworkPathCache.key(it, requested) }
// Unresolved (null: still asking the core) is distinct from resolved-to-nothing: only the latter shows the gradient.
val state = remember(cacheKey) { mutableStateOf(cacheKey?.let { ArtworkPathCache.get(it) }?.let { Resolved(it) }) }
LaunchedEffect(cacheKey) {
if (state.value?.path != null) return@LaunchedEffect
val path = client.artworkPath(coverArt, requested)
if (cacheKey != null && path != null) ArtworkPathCache.put(cacheKey, path)
state.value = Resolved(path)
}
Box(modifier.clip(shape)) {
ArtworkPlaceholder(coverArt, Modifier.fillMaxSize())
path?.let {
val resolved = state.value
val platformContext = LocalPlatformContext.current
Box(modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceContainerHigh)) {
val r = resolved
if (coverArt == null || (r != null && r.path == null)) ArtworkPlaceholder(coverArt, Modifier.fillMaxSize())
val path = r?.path
if (path != null) {
val request = remember(path) {
ImageRequest.Builder(platformContext).data(File(path.removePrefix("file://"))).crossfade(ARTWORK_CROSSFADE_MS).build()
}
AsyncImage(
model = File(it.removePrefix("file://")),
model = request,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onError = { cacheKey?.let { ArtworkPathCache.remove(it) } },
)
}
}
}

private class Resolved(val path: String?)

private const val ARTWORK_CROSSFADE_MS = 300

/**
* Process-wide memory of `Query.Artwork` answers ("id@size" to a file path), bounded LRU. Only
* found files are kept, so art that arrives later (a download, going online) is still picked up.
*/
object ArtworkPathCache {
private const val MAX = 1024
private val map = object : LinkedHashMap<String, String>(256, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > MAX
}
fun key(id: String, size: Int): String = "$id@$size"
fun get(key: String): String? = synchronized(map) { map[key] }
fun put(key: String, path: String) { synchronized(map) { map[key] = path } }
fun remove(key: String) { synchronized(map) { map.remove(key) } }
}

@Composable
fun ArtworkPlaceholder(id: String?, modifier: Modifier = Modifier) {
val seed = remember(id) { ArtworkColors.seedFor(id) }
Expand Down
48 changes: 38 additions & 10 deletions android/app/src/main/java/app/hocket/ui/components/Rows.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package app.hocket.ui.components

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ripple
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -84,17 +89,26 @@ fun SelectableRow(
state: String? = null,
actions: List<CustomAccessibilityAction> = emptyList(),
clickLabel: String? = null,
/** Shared with a child that draws the press ripple itself (grid cells ripple on the artwork only). */
interactionSource: MutableInteractionSource? = null,
/** False when a child draws the press indication from [interactionSource]. */
rowIndication: Boolean = true,
content: @Composable () -> Unit,
) {
val haptics = LocalHapticFeedback.current
val container by animateColorAsState(
if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, label = "rowSelected",
)
val selectLabel = stringResource(R.string.a11y_select)
val deselectLabel = stringResource(R.string.a11y_deselect)
val openLabel = clickLabel ?: stringResource(R.string.a11y_open)
Surface(
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface,
color = container,
modifier = modifier
.fillMaxWidth()
.combinedClickable(
interactionSource = interactionSource,
indication = if (rowIndication) ripple() else null,
onClick = { if (selectionActive) onToggleSelect() else onClick() },
onClickLabel = if (!selectionActive) openLabel else if (selected) deselectLabel else selectLabel,
onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onToggleSelect() },
Expand Down Expand Up @@ -201,8 +215,8 @@ fun TrackRow(
leading?.invoke()
if (showArtwork) {
Box(Modifier.size(48.dp)) {
Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp))
if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) {
Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner))
if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) }
}
}
Expand Down Expand Up @@ -266,21 +280,35 @@ fun AlbumCard(
) {
val artist = album.artist ?: stringResource(R.string.unknown_artist)
val label = stringResource(R.string.row_album_a11y, album.name, artist)
SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier) {
val interaction = remember { MutableInteractionSource() }
SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier, interactionSource = interaction, rowIndication = false) {
Column(Modifier.padding(6.dp)) {
Box {
Artwork(album.coverArt, ArtworkSizes.GRID, null, Modifier.fillMaxWidth().aspectRatio(1f), RoundedCornerShape(16.dp))
// The press ripple is drawn on the cover only (Navic): the text below stays calm.
Artwork(album.coverArt, ArtworkSizes.GRID, null, Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(GridArtCorner)).indication(interaction, ripple()), RoundedCornerShape(GridArtCorner))
if (selected) Box(Modifier.padding(8.dp)) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) }
}
}
Spacer(Modifier.height(8.dp))
Text(album.name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
GridCellText(album.name, artist)
}
}
}

/**
* The two text lines under a grid or carousel cell: a title of up to two lines (always two lines
* tall, so cells in a row and their loading skeletons line up) and a one-line subtitle.
*/
@Composable
fun GridCellText(title: String, subtitle: String) {
Text(title, style = GridTitleStyle, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}

internal val GridTitleStyle: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.titleSmallEmphasized

@Composable
fun ArtistRow(artist: Artist, onClick: () -> Unit, modifier: Modifier = Modifier, selected: Boolean = false, selectionActive: Boolean = false, onToggleSelect: () -> Unit = {}) {
val label = stringResource(R.string.row_artist_a11y, artist.name, artist.albumCount.toInt())
Expand All @@ -305,7 +333,7 @@ fun PlaylistRow(playlist: Playlist, onClick: () -> Unit, modifier: Modifier = Mo
).joinToString(", ")
SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier, state = offlineStateText(playlist.offline)) {
Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Artwork(playlist.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp))
Artwork(playlist.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(playlist.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Expand Down Expand Up @@ -342,8 +370,8 @@ fun Badge(text: String, modifier: Modifier = Modifier, container: androidx.compo

@Composable
fun SectionHeader(title: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null) {
Row(modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f).semantics { heading() })
Row(modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() })
action?.invoke()
}
}
Loading
Loading