diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 535fd2e..202378f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.border @@ -20,9 +21,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.text.font.FontWeight 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.unit.dp 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.LibraryTab import com.novasonic.app.viewmodel.LibraryViewModel +import kotlinx.coroutines.launch @Composable fun LibraryScreen( @@ -145,43 +150,133 @@ fun LibraryScreen( } } - LibraryTab.ARTISTS -> LazyColumn( - contentPadding = PaddingValues(vertical = 8.dp), - modifier = Modifier.fillMaxSize(), - ) { - state.artistIndexes.forEach { idx -> - item(key = "header_${idx.name}") { - Text( - text = idx.name, - style = MaterialTheme.typography.labelSmall, - color = NsAccent, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) + LibraryTab.ARTISTS -> { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } } + // Map letter → flat LazyColumn index of its header item + val letterToIndex = remember(state.artistIndexes) { + buildMap { + var flat = 0 + state.artistIndexes.forEach { idx -> + put(idx.name, flat) + flat += 1 + idx.artist.size + } } - items(idx.artist, key = { it.id }) { artist -> - 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), + } + var activeLetter by remember { mutableStateOf(null) } + + Box(Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp), + modifier = Modifier.fillMaxSize(), + ) { + state.artistIndexes.forEach { idx -> + item(key = "header_${idx.name}") { + Text( + text = idx.name, + style = MaterialTheme.typography.labelSmall, + color = NsAccent, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), ) - }, - modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) }, - colors = ListItemDefaults.colors(containerColor = NsBackground), - ) + } + items(idx.artist, key = { it.id }) { artist -> + 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, + ) + } + } + } } } }