feat(android): artist fast-scroll index bar with letter bubble

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-18 20:06:44 +02:00
1 parent dd484df636
commit 9ffd071c58
1 file changed
+130 -35
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -20,9 +21,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -37,6 +41,7 @@ import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel import com.novasonic.app.viewmodel.LibraryViewModel
import kotlinx.coroutines.launch
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
@@ -145,43 +150,133 @@ fun LibraryScreen(
} }
} }
LibraryTab.ARTISTS -> LazyColumn( LibraryTab.ARTISTS -> {
contentPadding = PaddingValues(vertical = 8.dp), val listState = rememberLazyListState()
modifier = Modifier.fillMaxSize(), val scope = rememberCoroutineScope()
) { val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
state.artistIndexes.forEach { idx -> // Map letter → flat LazyColumn index of its header item
item(key = "header_${idx.name}") { val letterToIndex = remember(state.artistIndexes) {
Text( buildMap<String, Int> {
text = idx.name, var flat = 0
style = MaterialTheme.typography.labelSmall, state.artistIndexes.forEach { idx ->
color = NsAccent, put(idx.name, flat)
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), flat += 1 + idx.artist.size
) }
} }
items(idx.artist, key = { it.id }) { artist -> }
ListItem( var activeLetter by remember { mutableStateOf<String?>(null) }
headlineContent = {
Text(artist.name, fontFamily = Outfit, Box(Modifier.fillMaxSize()) {
fontWeight = FontWeight.Medium, fontSize = 15.sp) LazyColumn(
}, state = listState,
supportingContent = if (artist.albumCount > 0) { { contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
Text("${artist.albumCount} Alben", modifier = Modifier.fillMaxSize(),
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2) ) {
} } else null, state.artistIndexes.forEach { idx ->
leadingContent = { item(key = "header_${idx.name}") {
AsyncImage( Text(
model = coverUrl(artist.coverArt), text = idx.name,
contentDescription = artist.name, style = MaterialTheme.typography.labelSmall,
contentScale = ContentScale.Crop, color = NsAccent,
modifier = Modifier modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
.size(52.dp)
.clip(CircleShape)
.background(NsSurface2),
) )
}, }
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) }, items(idx.artist, key = { it.id }) { artist ->
colors = ListItemDefaults.colors(containerColor = NsBackground), ListItem(
) headlineContent = {
Text(artist.name, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp)
},
supportingContent = if (artist.albumCount > 0) { {
Text("${artist.albumCount} Alben",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
} } else null,
leadingContent = {
AsyncImage(
model = coverUrl(artist.coverArt),
contentDescription = artist.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(NsSurface2),
)
},
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
colors = ListItemDefaults.colors(containerColor = NsBackground),
)
}
}
}
if (letters.isNotEmpty()) {
// Bubble showing active letter while dragging
activeLetter?.let { letter ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = (-44).dp)
.size(52.dp)
.clip(CircleShape)
.background(NsAccent),
) {
Text(
text = letter,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = NsBackground,
)
}
}
// Vertical letter strip
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(28.dp)
.pointerInput(letters) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val y = event.changes.firstOrNull()?.position?.y ?: continue
val idx = ((y / size.height) * letters.size)
.toInt().coerceIn(0, letters.lastIndex)
val letter = letters[idx]
when (event.type) {
PointerEventType.Press,
PointerEventType.Move -> {
event.changes.forEach { it.consume() }
if (letter != activeLetter) {
activeLetter = letter
letterToIndex[letter]?.let { pos ->
scope.launch { listState.scrollToItem(pos) }
}
}
}
else -> activeLetter = null
}
}
}
},
) {
Column(
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
letters.forEach { letter ->
Text(
text = letter,
fontSize = 10.sp,
fontWeight = if (letter == activeLetter) FontWeight.Bold else FontWeight.Normal,
color = if (letter == activeLetter) NsAccent else NsOnSurface3,
textAlign = TextAlign.Center,
)
}
}
}
} }
} }
} }