diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ScreenSolfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ScreenSolfa.kt index 9d1fc4a..15828fc 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ScreenSolfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ScreenSolfa.kt @@ -1,6 +1,7 @@ package mg.dot.feufaro import SharedScreenModel +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures @@ -8,14 +9,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -26,12 +26,14 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mg.dot.feufaro.data.GridTUOData import mg.dot.feufaro.solfa.ColorPrefixB @@ -88,6 +90,16 @@ object ScreenSolfa : Screen { } val activeFilePath = sharedScreenModel.activeFilePath.value + val allowedStanzas by sharedScreenModel.allowedStanzas.collectAsState() + val playlistStanzas by sharedScreenModel.playlistStanzas.collectAsState() + + LaunchedEffect(playlistStanzas, activeFilePath) { + if (playlistStanzas.isNotEmpty() && stanza !in playlistStanzas) { + sharedScreenModel.setStanza(playlistStanzas.first()) + scrollState.scrollTo(0) + } + } + val drawerItems by sharedScreenModel.drawerItems.collectAsState() val altFiles = remember(activeFilePath, drawerItems) { if (activeFilePath.isBlank()) { @@ -149,37 +161,88 @@ object ScreenSolfa : Screen { } ) } - .pointerInput(stanza, nbStanzas) { - var totalDragX = 0f - detectHorizontalDragGestures( - onDragStart = { totalDragX = 0f }, - onDragEnd = { - val currentStanza = stanza.toString().toIntOrNull() ?: 1 - val threshold = 70f + .pointerInput(stanza, allowedStanzas, activeFilePath) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val pressed = event.changes.filter { it.pressed } + if (pressed.isEmpty()) continue - if (totalDragX < -threshold) { - // Droite -> Gauche - if (currentStanza < nbStanzas) { - sharedScreenModel.setStanza(currentStanza + 1) - coroutineScope.launch { - scrollState.animateScrollTo(0) + val isTwoFingers = pressed.size >= 2 + val startX = if (isTwoFingers) (pressed[0].position.x + pressed[1].position.x) / 2f else pressed[0].position.x + val startY = if (isTwoFingers) (pressed[0].position.y + pressed[1].position.y) / 2f else pressed[0].position.y + + var currentX = startX + var isHorizontalDrag = false + + try { + if (isTwoFingers) pressed.forEach { it.consume() } + + while (true) { + val moveEvent = awaitPointerEvent() + val movePressed = moveEvent.changes.filter { it.pressed } + + if (isTwoFingers && movePressed.size < 2) break + if (!isTwoFingers && movePressed.size != 1) break + + currentX = if (isTwoFingers) { + (movePressed[0].position.x + movePressed[1].position.x) / 2f + } else { + movePressed.first().position.x + } + + val currentY = if (isTwoFingers) { + (movePressed[0].position.y + movePressed[1].position.y) / 2f + } else { + movePressed.first().position.y + } + + val diffX = kotlin.math.abs(currentX - startX) + val diffY = kotlin.math.abs(currentY - startY) + + if (diffX > 15f || diffY > 15f) { + if (diffX > diffY) { + isHorizontalDrag = true + if (isTwoFingers) movePressed.forEach { it.consume() } + else movePressed.first().consume() + } else { + break + } } } - } else if (totalDragX > threshold) { - // Gauche -> Droite - if (currentStanza > 1) { - sharedScreenModel.setStanza(currentStanza - 1) - coroutineScope.launch { - scrollState.animateScrollTo(0) + } catch (e: Exception) {} + + if (isHorizontalDrag) { + val totalDragX = currentX - startX + val threshold = 70f + + if (isTwoFingers) { + val path = when { + totalDragX < -threshold -> sharedScreenModel.getNextPlaylistItemPath() + totalDragX > threshold -> sharedScreenModel.getPrevPlaylistItemPath() + else -> null + } + if (!path.isNullOrBlank()) { + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(path) + } + } else { + val currentStanza = stanza.toString().toIntOrNull() ?: 1 + val target = when { + totalDragX < -threshold -> sharedScreenModel.nextAllowedStanza(currentStanza) + totalDragX > threshold -> sharedScreenModel.prevAllowedStanza(currentStanza) + else -> null + } + if (target != null) { + sharedScreenModel.setStanza(target) + coroutineScope.launch { + scrollState.animateScrollTo(0) + } } } } - }, - onHorizontalDrag = { change, dragAmount -> - change.consume() - totalDragX += dragAmount } - ) + } } .onGloballyPositioned { coordinates -> viewportHeight = coordinates.size.height @@ -206,28 +269,134 @@ object ScreenSolfa : Screen { Column( modifier = Modifier .fillMaxSize() - ) { - FlowRow( - modifier = Modifier.fillMaxWidth() - //.windowInsetsPadding(WindowInsets.safeDrawing) - .padding(start = 8.dp, end = 8.dp, top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val measureString: String by sharedScreenModel.measure.collectAsState() + val songTitle: String by sharedScreenModel.songTitle.collectAsState() + val songKey: String by sharedScreenModel.songKey.collectAsState() + val favItems by sharedScreenModel.favItems.collectAsState() + val isFavorite = favItems.any { it.path == activeFilePath } + val isAndroid = remember { getPlatform().name.startsWith("Android") } + + var playlistMessage by remember { mutableStateOf(null) } + LaunchedEffect(playlistMessage) { + if (playlistMessage != null) { + delay(2500) + playlistMessage = null + } + } + val selectedPlaylist by sharedScreenModel.selectedPlaylist.collectAsState() + val playlistsUi by sharedScreenModel.playlistsUi.collectAsState() + val isInPlaylist = remember(activeFilePath, selectedPlaylist, playlistsUi) { + sharedScreenModel.isSongInSelectedPlaylist(activeFilePath) + } + + val onFavorite = { sharedScreenModel.toFavorite(activeFilePath) } + val onTogglePlaylist: () -> Unit = { + coroutineScope.launch { + if (isInPlaylist) { + sharedScreenModel.removeSongFromPlaylist(selectedPlaylist, activeFilePath) + playlistMessage = "Retiré de « $selectedPlaylist »" + } else { + val r = sharedScreenModel.addSongToPlaylist( + songTitle = songTitle, + path = activeFilePath, + ) + playlistMessage = when { + r == null -> "Erreur : ajout impossible" + r.alreadyPresent -> "Déjà dans « ${r.playlistName} »" + else -> "Ajouté à « ${r.playlistName} »" + } + } + } + } + + if (isAndroid) { + // ANDROID + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 8.dp) ) { - val measureString: String by sharedScreenModel.measure.collectAsState() - val songTitle: String by sharedScreenModel.songTitle.collectAsState() - val songKey: String by sharedScreenModel.songKey.collectAsState() - Text(text = songTitle, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + val sTitlewStz = if(playlistStanzas.isNotEmpty()) "$songTitle: ${playlistStanzas.joinToString(", ")}" else songTitle + Text( + text = sTitlewStz, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = songKey, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)) + .padding(horizontal = 4.dp) + ) + Text(text = measureString, style = MaterialTheme.typography.bodyLarge) + Text(text = "Stanza: $stanza", style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.weight(1f)) + SongActionButtons( + isFavorite = isFavorite, + isInPlaylist = isInPlaylist, + enabled = activeFilePath.isNotBlank(), + onFavorite = onFavorite, + onTogglePlaylist = onTogglePlaylist, + modifier = Modifier.padding(end = 44.dp) + ) + } + } + } else { + // DESKTOP + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + val sTitlewStz = if(playlistStanzas.isNotEmpty()) "$songTitle: ${playlistStanzas.joinToString(", ")}" else songTitle + Text( + text = sTitlewStz, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) Text( text = songKey, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)) + modifier = Modifier + .background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)) .padding(horizontal = 4.dp) ) Text(text = measureString, style = MaterialTheme.typography.bodyLarge) Text(text = "Stanza: $stanza", style = MaterialTheme.typography.bodyLarge) - //ScreenTranspose.Content() + Spacer(Modifier.weight(1f)) + SongActionButtons( + isFavorite = isFavorite, + isInPlaylist = isInPlaylist, + enabled = activeFilePath.isNotBlank(), + onFavorite = onFavorite, + onTogglePlaylist = onTogglePlaylist, + modifier = Modifier.padding(end = 44.dp) + ) } + } + + playlistMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp, top = 2.dp) + ) + } LazyVerticalGridTUO( gridTUOData, gridWidthPx = gridWidthPx, @@ -316,16 +485,32 @@ object ScreenSolfa : Screen { verticalAlignment = Alignment.CenterVertically ) { val nextLabel: String by sharedScreenModel.nextLabel.collectAsState() + val prevLabel: String by sharedScreenModel.prevLabel.collectAsState() - if (nextLabel.isNotBlank()) { - MGButton( - onClick = { - solfaScreenModel.loadNextInPlaylist() - }, - modifier = Modifier.height(40.dp) - ) { - Text(nextLabel) - } + MGButton( + onClick = { + val prevPath = sharedScreenModel.getPrevPlaylistItemPath() + if (!prevPath.isNullOrBlank()) { + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(prevPath) + } + }, + modifier = Modifier.height(40.dp) + ) { + Text(prevLabel) + } + + MGButton( + onClick = { + val nextPath = sharedScreenModel.getNextPlaylistItemPath() + if (!nextPath.isNullOrBlank()) { + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(nextPath) + } + }, + modifier = Modifier.height(40.dp) + ) { + Text(nextLabel) } } } @@ -338,6 +523,49 @@ object ScreenSolfa : Screen { } } + @Composable + private fun SongActionButtons( + isFavorite: Boolean, + isInPlaylist: Boolean, + enabled: Boolean, + onFavorite: () -> Unit, + onTogglePlaylist: () -> Unit, + modifier: Modifier = Modifier, + ) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedIconButton( + onClick = onFavorite, + enabled = enabled, + modifier = Modifier.size(36.dp), + border = BorderStroke( + 1.dp, + if (isFavorite) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Icon( + imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = if (isFavorite) "Retirer des favoris" else "Ajouter aux favoris", + tint = if (isFavorite) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + OutlinedIconButton( + onClick = onTogglePlaylist, + enabled = enabled, + modifier = Modifier.size(36.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + ) { + Icon( + imageVector = if (isInPlaylist) Icons.Default.PlaylistRemove else Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = if (isInPlaylist) "Retirer de la playlist" else "Ajouter à la playlist", + tint = if (isInPlaylist) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + } + @Throws(ObjectStreamException::class) // C'est une méthode de sérialisation Java, donc l'exception est nécessaire private fun readResolve(): Any { return this // Toujours retourner l'instance unique de ce singleton diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/DrawerItem.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/DrawerItem.kt index d52f7f3..132a948 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/DrawerItem.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/DrawerItem.kt @@ -15,7 +15,8 @@ data class DrawerItem( val title: String, val contentTitle: String, val firstNotes: String = "", - val path: String + val path: String, + val totalStanzas: Int = 1 ) { private data class TitleComponents( val prefix: String, @@ -32,7 +33,6 @@ data class DrawerItem( val version = match.groupValues[3].ifEmpty { null } return TitleComponents(prefix, number, version) } - fun matchesQuery(query: String): Boolean { if (query.isBlank()) return true val cleanQuery = query.normalizeForSearch() @@ -47,7 +47,9 @@ data class DrawerItem( val titleComponents = parseComponents(title) if (queryComponents != null && titleComponents != null) { if (queryComponents.prefix == titleComponents.prefix) { - return true + if (queryComponents.number == titleComponents.number || titleComponents.number.startsWith(queryComponents.number)) { + return true + } } } @@ -176,13 +178,15 @@ suspend fun getCombinedList(fileRepository: FileRepository): List { resolveContent = resolveInternalContent ) + val totalStanzas = extractTotalStanzas(content) finalList.add( DrawerItem( id = item.id, title = item.fileName.removeSuffix(".txt"), contentTitle = item.title, firstNotes = notes, - path = item.path + path = item.path, + totalStanzas = totalStanzas ) ) } @@ -207,7 +211,8 @@ suspend fun getCombinedList(fileRepository: FileRepository): List { title = file.nameWithoutExtension, contentTitle = extractTitle(file), firstNotes = extractFirstNotes(file, localBaseKeysWithAlt), - path = file.absolutePath + path = file.absolutePath, + totalStanzas = extractTotalStanzas(file) ) ) } @@ -219,6 +224,28 @@ suspend fun getCombinedList(fileRepository: FileRepository): List { return finalList } +private val stanzaRegex = Regex("""^[EY](\d+):""") + +private fun extractTotalStanzas(content: String): Int { + if (content.isBlank()) return 1 + val numbers = content.lines().mapNotNull { line -> + stanzaRegex.find(line.trim())?.groupValues?.get(1)?.toIntOrNull() + } + return numbers.maxOrNull() ?: 1 +} + +private fun extractTotalStanzas(file: File): Int { + return try { + val numbers = file.useLines { lines -> + lines.mapNotNull { line -> + stanzaRegex.find(line.trim())?.groupValues?.get(1)?.toIntOrNull() + }.toList() + } + numbers.maxOrNull() ?: 1 + } catch (e: Exception) { + 1 + } +} private fun extractTitle(content: String): String? { val regex = Regex("""\|t:([^|]+)""") return regex.find(content)?.groupValues?.get(1)?.trim() diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/PlaylistsModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/PlaylistsModel.kt new file mode 100644 index 0000000..e6054fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/data/PlaylistsModel.kt @@ -0,0 +1,24 @@ +package mg.dot.feufaro.data + +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistsFile( + val playlist: List>> +) + +@Serializable +data class PlaylistSongEntry( + val id: Int, + val chant: String, + val path: String, + val stanzas: List +) + +data class PlaylistWithSongs(val name: String, val entries: List) + +data class PlaylistSongUi(val entry: PlaylistSongEntry, val item: DrawerItem?) + +data class PlaylistUi(val name: String, val songs: List) + +data class ActivePlaylistSong(val path: String, val stanzas: List) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt index 2866936..c93c1e8 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -274,7 +274,13 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val midiWriter = MidiWriterKotlin(fileRepository) midiWriter.process(pitches) midiWriter.save("whawyd3.mid") - sharedScreenModel.setStanza(1) + val allowed = sharedScreenModel.allowedStanzas.value + val currentStz = sharedScreenModel.stanza.value + if (currentStz !in allowed && allowed.isNotEmpty()) { + sharedScreenModel.setStanza(allowed.first()) + } else if (currentStz == 0) { + sharedScreenModel.setStanza(1) + } } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index da68338..0ed108e 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import mg.dot.feufaro.data.ActivePlaylistSong import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions @@ -73,6 +74,7 @@ fun MainScreenWithDrawer( val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var showSettingsDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) } + var showPlaylistDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val items by sharedScreenModel.drawerItems.collectAsState() @@ -233,6 +235,9 @@ fun MainScreenWithDrawer( onSongSelected = { newSong -> sharedScreenModel.loadNewSong("$midiFile") }, + onPlaylistSreen = { + showPlaylistDialog = true + }, onSettingsCheck = { showSettingsDialog = true }, @@ -287,7 +292,77 @@ fun MainScreenWithDrawer( if (showAboutDialog) { AboutDialog(onDismiss = { showAboutDialog = false }) } - val playlistPaths by sharedScreenModel.playlistItems.collectAsState() + + LaunchedEffect(Unit) { + sharedScreenModel.loadPlaylists() + } + + val playlistNames by sharedScreenModel.playlistNames.collectAsState() + val selectedPlaylist by sharedScreenModel.selectedPlaylist.collectAsState() + val currentPlaylistRawText by sharedScreenModel.currentPlaylistRawText.collectAsState() + + if (showPlaylistDialog) { + PlaylistDialog( + playlistNames = playlistNames, + selectedPlaylist = selectedPlaylist, + onSelectPlaylist = { selected -> + scope.launch { sharedScreenModel.selectPlaylist(selected) } + }, + onNewPlaylist = { newName -> + scope.launch { sharedScreenModel.createPlaylist(newName) } + }, + onRenamePlaylist = { oldName, newName -> + scope.launch { sharedScreenModel.renamePlaylist(oldName, newName) } + }, + onDeletePlaylist = { playlistName -> + scope.launch { + sharedScreenModel.deletePlaylist(playlistName) + } + }, + initialRawText = currentPlaylistRawText, + drawerItems = items, + onAddFromLibrary = { }, + onSave = { updatedRawText -> + scope.launch { + sharedScreenModel.savePlaylist(selectedPlaylist, updatedRawText) + showPlaylistDialog = false + } + }, + onPlay = { updatedRawText -> + scope.launch { + sharedScreenModel.savePlaylist(selectedPlaylist, updatedRawText) + sharedScreenModel.selectPlaylist(selectedPlaylist) + + val songs = parseRawTextToEntries(updatedRawText).mapNotNull { entry -> + val song = findMatchingSong(entry.query, items) ?: return@mapNotNull null + ActivePlaylistSong( + path = song.path, + stanzas = parseVerses(entry.verseStr)?.sorted() ?: emptyList() + ) + } + val firstPath = sharedScreenModel.startPlaylist(songs) + + showPlaylistDialog = false + + if (firstPath != null) { + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(firstPath) + sharedScreenModel.loadNewSong(midiFile) + } + } + }, + onDismiss = { showPlaylistDialog = false } + ) + } + + val playlists by sharedScreenModel.playlistsUi.collectAsState() + val lastPlaylistPaths = remember(playlists) { + playlists.lastOrNull() + ?.songs + ?.flatMap { listOfNotNull(it.entry.path, it.item?.path) } + ?.toSet() + ?: emptySet() + } val favoritePaths by sharedScreenModel.favItems.collectAsState() val keyboardController = LocalSoftwareKeyboardController.current @@ -1170,7 +1245,7 @@ fun MainScreenWithDrawer( } itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item -> val isFavorite = favoritePaths.contains(item) - val onPlaylist = playlistPaths.contains(item) + val onPlaylist = item.path in lastPlaylistPaths val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { dismissValue -> if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) { @@ -1224,12 +1299,16 @@ fun MainScreenWithDrawer( supportingContent = { Text(item.contentTitle, maxLines = 1) }, trailingContent = { IconButton( - onClick = { sharedScreenModel.toPlaylist(item.path) }, + onClick = { + scope.launch { + sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path) + } + }, modifier = Modifier.size(35.dp) ) { Icon( imageVector = Icons.AutoMirrored.Default.PlaylistAdd, - contentDescription = "Btn add playlist", + contentDescription = if (onPlaylist) "Déjà dans la playlist" else "Btn add playlist", tint = if (!onPlaylist) Color.LightGray else Color( 0xFFFFD700 ) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PlaylistDialog.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PlaylistDialog.kt new file mode 100644 index 0000000..d6f9691 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/PlaylistDialog.kt @@ -0,0 +1,1064 @@ +package mg.dot.feufaro.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +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 +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.zIndex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import mg.dot.feufaro.data.DrawerItem +import mg.dot.feufaro.data.normalizeForSearch +import mg.dot.feufaro.getPlatform + +private const val SHOW_LAST_FIRST = true + +sealed interface PlaylistRowUi { + val id: Long + val raw: String +} + +data class PlaylistSongRow( + override val id: Long, + override val raw: String, + val songKey: String, + val title: String, + val totalVerses: Int, + val selectedVerses: Set?, +) : PlaylistRowUi + +data class PlaylistErrorRow( + override val id: Long, + override val raw: String, + val message: String, +) : PlaylistRowUi + +data class ParsedEntry( + val query: String, + val verseStr: String, + val raw: String +) + + +fun parseVerses(versesStr: String): Set? { + val clean = versesStr.trim() + if (clean.isEmpty() || clean == "0") return null + val result = mutableSetOf() + val parts = clean.split(",") + for (part in parts) { + val p = part.trim() + if (p.contains("-")) { + val rangeParts = p.split("-") + if (rangeParts.size == 2) { + val start = rangeParts[0].trim().toIntOrNull() + val end = rangeParts[1].trim().toIntOrNull() + if (start != null && end != null && start <= end) { + for (v in start..end) result.add(v) + } + } + } else { + val v = p.toIntOrNull() + if (v != null && v > 0) result.add(v) + } + } + return if (result.isEmpty()) null else result +} + +fun formatVerses(selectedVerses: Set?, totalVerses: Int): String { + if (selectedVerses == null || selectedVerses.isEmpty() || selectedVerses.size == totalVerses) { + return "" + } + return selectedVerses.sorted().joinToString(",") +} + +fun summarizeVerses(verses: Set): String { + val s = verses.sorted() + if (s.isEmpty()) return "" + val parts = mutableListOf() + var start = s[0] + var prev = s[0] + for (i in 1..s.size) { + val cur = s.getOrNull(i) + if (cur != null && cur == prev + 1) { + prev = cur + continue + } + parts += when { + prev == start -> "$start" + prev == start + 1 -> "$start,$prev" + else -> "$start-$prev" + } + if (cur != null) { + start = cur + prev = cur + } + } + return parts.joinToString(",") +} + +fun parseRawTextToEntries(rawText: String): List { + if (rawText.isBlank()) return emptyList() + + return rawText.split(";").mapNotNull { chunk -> + val rawItem = chunk.trim() + if (rawItem.isEmpty()) return@mapNotNull null + + if (rawItem.contains(":")) { + val parts = rawItem.split(":", limit = 2) + val queryPart = parts[0].trim() + val versePart = parts.getOrNull(1)?.trim() ?: "" + ParsedEntry(query = queryPart, verseStr = versePart, raw = rawItem) + } else { + ParsedEntry(query = rawItem, verseStr = "", raw = rawItem) + } + } +} + +fun findMatchingSong(query: String, drawerItems: List): DrawerItem? { + val q = query.trim() + if (q.isEmpty()) return null + + val matches = drawerItems.filter { it.matchesQuery(q) } + if (matches.isNotEmpty()) { + return matches.maxByOrNull { it.getSearchScore(q) } + } + + val normQ = q.lowercase().replace(Regex("[^a-z0-9]"), "") + return drawerItems.firstOrNull { item -> + val normTitle = item.title.lowercase().replace(Regex("[^a-z0-9]"), "") + val normPath = item.path.lowercase().replace(Regex("[^a-z0-9]"), "") + normTitle.contains(normQ) || normPath.contains(normQ) + } +} + +fun parseRawTextToRows(rawText: String, drawerItems: List = emptyList()): List { + val entries = parseRawTextToEntries(rawText) + val rows = mutableListOf() + val seen = mutableMapOf() + + for (entry in entries) { + val matchedSong = findMatchingSong(entry.query, drawerItems) + val selectedVerses = parseVerses(entry.verseStr) + + val baseKey = if (matchedSong != null) "s${matchedSong.id}" else "e${entry.query.normalizeForSearch()}" + val occurrence = seen.getOrElse(baseKey) { 0 } + seen[baseKey] = occurrence + 1 + val stableId = baseKey.hashCode().toLong() * 1000L + occurrence + + if (matchedSong != null) { + rows.add( + PlaylistSongRow( + id = stableId, + raw = entry.raw, + songKey = matchedSong.title.ifBlank { entry.query }, + title = matchedSong.contentTitle.ifBlank { "Titre du chant" }, + totalVerses = matchedSong.totalStanzas, + selectedVerses = selectedVerses + ) + ) + } else { + rows.add(PlaylistErrorRow(id = stableId, raw = entry.raw, message = "chant non trouvé")) + } + } + return rows +} + +fun buildRawTextFromRows(rows: List): String { + return rows.joinToString("; ") { row -> + when (row) { + is PlaylistSongRow -> { + val verseStr = formatVerses(row.selectedVerses, row.totalVerses) + if (verseStr.isEmpty()) row.songKey else "${row.songKey}:$verseStr" + } + is PlaylistErrorRow -> row.raw + } + } +} + +fun computeSuggestions(rawText: String, drawerItems: List, limit: Int = 8): List { + val lastChunk = rawText.substringAfterLast(';').trim() + if (lastChunk.length < 2 || lastChunk.contains(':')) return emptyList() + + val normChunk = lastChunk.normalizeForSearch() + if (drawerItems.any { it.title.normalizeForSearch() == normChunk }) return emptyList() + + return drawerItems + .filter { it.matchesQuery(lastChunk) } + .sortedByDescending { it.getSearchScore(lastChunk) } + .take(limit) + .map { it.title } +} + +private fun applySuggestion(rawText: String, suggestion: String): String { + val prefix = if (rawText.contains(';')) rawText.substringBeforeLast(';').trimEnd() + "; " else "" + return prefix + suggestion +} + + +@Composable +fun PlaylistDialog( + playlistNames: List, + selectedPlaylist: String, + onSelectPlaylist: (String) -> Unit, + onNewPlaylist: (String) -> Unit = {}, + onRenamePlaylist: (oldName: String, newName: String) -> Unit = { _, _ -> }, + onDeletePlaylist: (String) -> Unit = {}, + initialRawText: String = "", + drawerItems: List = emptyList(), + onAddFromLibrary: () -> Unit = {}, + onSave: (rawText: String) -> Unit, + onPlay: (rawText: String) -> Unit, + onDismiss: () -> Unit, +) { + var rawText by rememberSaveable(initialRawText) { mutableStateOf(initialRawText) } + var rows by remember { mutableStateOf>(emptyList()) } + var suggestions by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(rawText, drawerItems) { + delay(100) + val result = withContext(Dispatchers.Default) { + parseRawTextToRows(rawText, drawerItems) to computeSuggestions(rawText, drawerItems) + } + rows = result.first + suggestions = result.second + } + + val isAndroid = remember { getPlatform().name.startsWith("Android") } + val errorCount = rows.count { it is PlaylistErrorRow } + val hasError = errorCount > 0 + val contentScroll = rememberScrollState() + + var playlistToRename by remember { mutableStateOf(null) } + var renameText by remember { mutableStateOf("") } + var showNewPlaylistDialog by remember { mutableStateOf(false) } + var newPlaylistNameText by remember { mutableStateOf("") } + var playlistToDelete by remember { mutableStateOf(null) } + + fun commit(newRows: List) { + rows = newRows + rawText = buildRawTextFromRows(newRows) + } + + fun toggleVerse(id: Long, v: Int) { + commit(rows.map { r -> + if (r is PlaylistSongRow && r.id == id) { + val full = (1..r.totalVerses).toSet() + val current = r.selectedVerses ?: full + val next = if (v in current) current - v else current + v + r.copy(selectedVerses = if (next.isEmpty() || next == full) null else next) + } else r + }) + } + + fun selectAllVerses(id: Long) { + commit(rows.map { r -> + if (r is PlaylistSongRow && r.id == id) r.copy(selectedVerses = null) else r + }) + } + + fun moveRow(id: Long, delta: Int) { + val i = rows.indexOfFirst { it.id == id } + val j = i + delta + if (i >= 0 && j in rows.indices) { + commit(rows.toMutableList().also { it.add(j, it.removeAt(i)) }) + } + } + + fun removeRow(id: Long) { + commit(rows.filterNot { it.id == id }) + } + + if (playlistToDelete != null) { + AlertDialog( + onDismissRequest = { playlistToDelete = null }, + title = { Text("Supprimer la playlist \"$playlistToDelete\" ?", fontWeight = FontWeight.Bold) }, + confirmButton = { + TextButton( + onClick = { + val target = playlistToDelete!! + playlistToDelete = null + onDeletePlaylist(target) + } + ) { + Text("Supprimer", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { playlistToDelete = null }) { Text("Annuler") } } + ) + } + + if (playlistToRename != null) { + AlertDialog( + onDismissRequest = { playlistToRename = null }, + title = { Text("Modifier le nom de la playlist", fontWeight = FontWeight.Bold) }, + text = { + OutlinedTextField( + value = renameText, + onValueChange = { renameText = it }, + label = { Text("Nom de la playlist") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + val old = playlistToRename!! + if (renameText.isNotBlank() && renameText != old) { + onRenamePlaylist(old, renameText.trim()) + } + playlistToRename = null + } + ) { Text("Enregistrer", fontWeight = FontWeight.Bold) } + }, + dismissButton = { TextButton(onClick = { playlistToRename = null }) { Text("Annuler") } } + ) + } + + if (showNewPlaylistDialog) { + AlertDialog( + onDismissRequest = { showNewPlaylistDialog = false }, + title = { Text("Nouvelle playlist", fontWeight = FontWeight.Bold) }, + text = { + OutlinedTextField( + value = newPlaylistNameText, + onValueChange = { newPlaylistNameText = it }, + label = { Text("Nom de la playlist") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + if (newPlaylistNameText.isNotBlank()) { + onNewPlaylist(newPlaylistNameText.trim()) + newPlaylistNameText = "" + showNewPlaylistDialog = false + } + } + ) { Text("Créer", fontWeight = FontWeight.Bold) } + }, + dismissButton = { TextButton(onClick = { showNewPlaylistDialog = false }) { Text("Annuler") } } + ) + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + modifier = Modifier + .widthIn( + min = if (isAndroid) 300.dp else 600.dp, + max = if (isAndroid) 600.dp else 900.dp + ) + .fillMaxWidth(0.9f), + onDismissRequest = { + onDismiss; + if (rawText != initialRawText) onSave(rawText); + }, + title = { + if (isAndroid) { + Text(text = "Playlist", fontWeight = FontWeight.Black, fontSize = 22.sp) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedPlaylist, + fontWeight = FontWeight.Black, + fontSize = 22.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + OutlinedButton( + onClick = { playlistToDelete = selectedPlaylist }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(50), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.7f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Supprimer", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } + } + }, + text = { + if (isAndroid) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 11.dp) + .verticalScroll(contentScroll), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + PlaylistSelector( + names = playlistNames, + selected = selectedPlaylist, + onSelect = onSelectPlaylist, + onNew = { showNewPlaylistDialog = true }, + onRename = { + playlistToRename = selectedPlaylist + renameText = selectedPlaylist + }, + onDelete = { playlistToDelete = selectedPlaylist } + ) + PlaylistEditor( + isAndroid = true, + rawText = rawText, + onRawTextChange = { rawText = it }, + rows = rows, + errorCount = errorCount, + suggestions = suggestions, + onToggleVerse = ::toggleVerse, + onSelectAllVerses = ::selectAllVerses, + onMove = ::moveRow, + onRemove = ::removeRow, + onAddFromLibrary = onAddFromLibrary, + ) + } + + MyVerticalScrollbar( + scrollState = contentScroll, + modifier = Modifier.align(Alignment.CenterEnd) + ) { } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + PlaylistSidebar( + names = playlistNames, + selected = selectedPlaylist, + onSelect = onSelectPlaylist, + onNew = { showNewPlaylistDialog = true }, + onRename = { name -> + playlistToRename = name + renameText = name + }, + modifier = Modifier.width(220.dp).fillMaxHeight() + ) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + MyVerticalScrollbar( + scrollState = contentScroll, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 11.dp) + .verticalScroll(contentScroll), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + PlaylistEditor( + isAndroid = false, + rawText = rawText, + onRawTextChange = { rawText = it }, + rows = rows, + errorCount = errorCount, + suggestions = suggestions, + onToggleVerse = ::toggleVerse, + onSelectAllVerses = ::selectAllVerses, + onMove = ::moveRow, + onRemove = ::removeRow, + onAddFromLibrary = onAddFromLibrary, + ) + } + } + } + } + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onSave(rawText) }, enabled = !hasError) { + Text("Enregistrer", fontWeight = FontWeight.Bold) + } + Button( + onClick = { onPlay(rawText) }, + enabled = !hasError, + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Lancer", fontWeight = FontWeight.Bold) + } + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) +} + + +@Composable +private fun ColumnScope.PlaylistEditor( + isAndroid: Boolean, + rawText: String, + onRawTextChange: (String) -> Unit, + rows: List, + errorCount: Int, + suggestions: List, + onToggleVerse: (Long, Int) -> Unit, + onSelectAllVerses: (Long) -> Unit, + onMove: (Long, Int) -> Unit, + onRemove: (Long) -> Unit, + onAddFromLibrary: () -> Unit, +) { + val hasError = errorCount > 0 + var expandedId by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + val keyboard = LocalSoftwareKeyboardController.current + var fieldValue by remember { mutableStateOf(TextFieldValue(rawText, TextRange(rawText.length))) } + + LaunchedEffect(rawText) { + if (rawText != fieldValue.text) { + fieldValue = TextFieldValue(rawText, TextRange(rawText.length)) + } + } + + OutlinedTextField( + value = fieldValue, + onValueChange = { newValue: TextFieldValue -> + fieldValue = newValue + onRawTextChange(newValue.text) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + label = { Text("Saisie rapide") }, + textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + minLines = 1, + maxLines = 4, + isError = hasError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), + shape = RoundedCornerShape(12.dp), + supportingText = if (hasError) { + { Text(if (errorCount == 1) "1 entrée à corriger" else "$errorCount entrées à corriger") } + } else null, + ) + + AnimatedVisibility(visible = suggestions.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(suggestions) { s -> + SuggestionChip( + onClick = { + val newText = applySuggestion(fieldValue.text, s) + fieldValue = TextFieldValue(newText, TextRange(newText.length)) + onRawTextChange(newText) + focusRequester.requestFocus() + keyboard?.show() + }, + label = { Text(s, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelLarge) }, + shape = RoundedCornerShape(50), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)), + ) + } + } + } + + if (rows.isNotEmpty()) { + Text( + text = "${rows.size} chants" + if (hasError) " · $errorCount à corriger" else "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp) + ) + } + + val ordered = if (SHOW_LAST_FIRST) rows.asReversed() else rows + val lastIndex = rows.lastIndex + val upDelta = if (SHOW_LAST_FIRST) 1 else -1 + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + ordered.forEach { row -> + val modelIndex = rows.indexOfFirst { it.id == row.id } + key(row.id) { + when (row) { + is PlaylistSongRow -> PlaylistSongCard( + row = row, + index = modelIndex + 1, + isAndroid = isAndroid, + expanded = expandedId == row.id, + canMoveUp = if (SHOW_LAST_FIRST) modelIndex < lastIndex else modelIndex > 0, + canMoveDown = if (SHOW_LAST_FIRST) modelIndex > 0 else modelIndex < lastIndex, + onToggleExpand = { expandedId = if (expandedId == row.id) null else row.id }, + onToggleVerse = { onToggleVerse(row.id, it) }, + onSelectAll = { onSelectAllVerses(row.id) }, + onMoveUp = { onMove(row.id, upDelta) }, + onMoveDown = { onMove(row.id, -upDelta) }, + onRemove = { onRemove(row.id) }, + ) + + is PlaylistErrorRow -> PlaylistErrorCard( + row = row, + onRemove = { onRemove(row.id) }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PlaylistSelector( + names: List, + selected: String, + onSelect: (String) -> Unit, + onNew: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + onClick = { open = true }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = Modifier.weight(1f).heightIn(min = 44.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selected, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Changer de playlist", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + OutlinedIconButton( + onClick = onRename, + modifier = Modifier.size(44.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + ) { + Icon(Icons.Default.Edit, contentDescription = "Renommer la playlist", modifier = Modifier.size(20.dp)) + } + + OutlinedIconButton( + onClick = onDelete, + modifier = Modifier.size(44.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.7f)), + colors = IconButtonDefaults.outlinedIconButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon(Icons.Default.Delete, contentDescription = "Supprimer la playlist", modifier = Modifier.size(20.dp)) + } + } + + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + names.forEach { name -> + DropdownMenuItem( + text = { Text(name, fontWeight = if (name == selected) FontWeight.Bold else FontWeight.Normal) }, + modifier = Modifier.background( + if (name == selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + else Color.Transparent + ), + onClick = { onSelect(name); open = false } + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Nouvelle playlist") }, + leadingIcon = { Icon(Icons.Default.Add, null) }, + onClick = { onNew(); open = false } + ) + } + } +} + +@Composable +private fun PlaylistSidebar( + names: List, + selected: String, + onSelect: (String) -> Unit, + onNew: () -> Unit, + onRename: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(12.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Playlists", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + IconButton(onClick = onNew, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Add, contentDescription = "Nouvelle playlist", modifier = Modifier.size(18.dp)) + } + } + HorizontalDivider() + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(names) { name -> + val isSelected = name == selected + Surface( + onClick = { onSelect(name) }, + shape = RoundedCornerShape(8.dp), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { onRename(name) }, modifier = Modifier.size(24.dp)) { + Icon( + Icons.Default.Edit, + contentDescription = "Renommer", + modifier = Modifier.size(14.dp), + tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PlaylistSongCard( + row: PlaylistSongRow, + index: Int, + isAndroid: Boolean, + expanded: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onToggleExpand: () -> Unit, + onToggleVerse: (Int) -> Unit, + onSelectAll: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, +) { + val allSelected = row.selectedVerses == null + val canExpand = row.totalVerses > 1 + val isOpen = expanded && canExpand + + var dragOffset by remember { mutableFloatStateOf(0f) } + var dragging by remember { mutableStateOf(false) } + var rowHeightPx by remember { mutableIntStateOf(0) } + val gapPx = with(LocalDensity.current) { 6.dp.toPx() } + val currentMoveUp by rememberUpdatedState(onMoveUp) + val currentMoveDown by rememberUpdatedState(onMoveDown) + val currentCanUp by rememberUpdatedState(canMoveUp) + val currentCanDown by rememberUpdatedState(canMoveDown) + + Card( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { rowHeightPx = it.height } + .zIndex(if (dragging) 1f else 0f) + .graphicsLayer { translationY = dragOffset }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (isOpen) MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f) + ), + border = if (isOpen) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else null, + elevation = CardDefaults.cardElevation(defaultElevation = if (dragging) 8.dp else 0.dp), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(enabled = canExpand, onClick = onToggleExpand) + .padding(start = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(32.dp) + .pointerInput(row.id) { + detectDragGestures( + onDragStart = { dragging = true }, + onDragEnd = { dragging = false; dragOffset = 0f }, + onDragCancel = { dragging = false; dragOffset = 0f }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount.y + val step = rowHeightPx + gapPx + if (dragOffset < -step * 0.6f && currentCanUp) { + currentMoveUp() + dragOffset += step + } else if (dragOffset > step * 0.6f && currentCanDown) { + currentMoveDown() + dragOffset -= step + } + } + ) + }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.DragHandle, + contentDescription = "Déplacer", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = "$index", + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(20.dp), + textAlign = TextAlign.Center + ) + Spacer(Modifier.width(6.dp)) + + Column(modifier = Modifier.weight(1f).padding(vertical = 4.dp)) { + Text( + row.songKey.uppercase(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + row.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (canExpand) { + VersesBadge(allSelected = allSelected, verses = row.selectedVerses) + Icon( + imageVector = if (isOpen) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isOpen) "Replier" else "Choisir les versets", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp).size(20.dp) + ) + } else { + VersesBadge(allSelected = false, verses = setOf(1)) + } + + if (!isAndroid || !canExpand) { + IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Close, "Retirer ${row.songKey}", modifier = Modifier.size(20.dp)) + } + } + } + + AnimatedVisibility(visible = isOpen) { + Column(modifier = Modifier.padding(start = 30.dp, end = 8.dp, bottom = 8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + FilterChip( + selected = allSelected, + onClick = onSelectAll, + label = { Text("Tous") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.secondary, + selectedLabelColor = MaterialTheme.colorScheme.onSecondary, + ), + shape = RoundedCornerShape(50), + ) + for (v in 1..row.totalVerses) { + FilterChip( + selected = allSelected || row.selectedVerses?.contains(v) == true, + onClick = { onToggleVerse(v) }, + label = { + Text("$v", modifier = Modifier.widthIn(min = 14.dp), textAlign = TextAlign.Center) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(50), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (isAndroid) { + TextButton( + onClick = onRemove, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("Retirer ce chant", fontWeight = FontWeight.SemiBold) + } + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onMoveUp, enabled = canMoveUp, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.ArrowUpward, "Monter", modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onMoveDown, enabled = canMoveDown, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.ArrowDownward, "Descendre", modifier = Modifier.size(20.dp)) + } + } + } + } + } + } +} + +@Composable +private fun VersesBadge(allSelected: Boolean, verses: Set?) { + val bg = if (allSelected) MaterialTheme.colorScheme.secondary.copy(alpha = 0.28f) + else MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + val fg = if (allSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .padding(start = 6.dp) + .heightIn(min = 24.dp) + .widthIn(max = 96.dp) + .clip(RoundedCornerShape(12.dp)) + .background(bg) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (allSelected || verses == null) "Tous" else summarizeVerses(verses), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = fg, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + + +@Composable +private fun PlaylistErrorCard( + row: PlaylistErrorRow, + onRemove: () -> Unit, +) { + val err = MaterialTheme.colorScheme.error + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = err.copy(alpha = 0.12f)), + border = BorderStroke(1.dp, err.copy(alpha = 0.6f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(start = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.ErrorOutline, contentDescription = "Erreur", tint = err, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + row.raw, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = err, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Spacer(Modifier.width(8.dp)) + Text( + row.message.ifEmpty { "chant non trouvé" }, + style = MaterialTheme.typography.bodySmall, + color = err, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Close, "Supprimer l'entrée ${row.raw}", tint = err, modifier = Modifier.size(20.dp)) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt index 182e235..bc54332 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FeaturedPlayList import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.automirrored.filled.NoteAdd import androidx.compose.material.icons.automirrored.filled.PlaylistPlay @@ -25,12 +26,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.icon import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import mg.dot.feufaro.data.ActivePlaylistSong import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.getPlatform import mg.dot.feufaro.solfa.Solfa @@ -48,6 +51,7 @@ fun SimpleDrawerContent( scope: CoroutineScope, onSongSelected: (String) -> Unit, onSettingsCheck: () -> Unit, + onPlaylistSreen: () -> Unit, onAboutCheck: () -> Unit, ) { var searchTxt by remember { mutableStateOf("") } @@ -59,16 +63,40 @@ fun SimpleDrawerContent( val internalList by sharedScreenModel.internalItems.collectAsState() val externalList by sharedScreenModel.externalItems.collectAsState() val playList by sharedScreenModel.playlistItems.collectAsState() + val playlists by sharedScreenModel.playlistsUi.collectAsState() val favoList by sharedScreenModel.favItems.collectAsState() val listState = rememberLazyListState() var internalExpanded by remember { mutableStateOf(false) } var externalExpanded by remember { mutableStateOf(false) } - var playListExpanded by remember { mutableStateOf(false) } var favoriteExpanded by remember { mutableStateOf(false) } - + val playListExpanded by sharedScreenModel.playlistSectionExpanded.collectAsState() + val expandedPlaylists by sharedScreenModel.expandedPlaylistNames.collectAsState() + var playlistToDelete by remember { mutableStateOf(null) } val editMode by sharedScreenModel.modeEditor.collectAsState() val chordView by sharedScreenModel.harmonyView.collectAsState() + playlistToDelete?.let { name -> + AlertDialog( + onDismissRequest = { playlistToDelete = null }, + title = { + Text("Supprimer la playlist \"$name\" ?", fontWeight = FontWeight.Bold) + }, + confirmButton = { + TextButton( + onClick = { + sharedScreenModel.deletePlaylist(name) + playlistToDelete = null + } + ) { + Text("Supprimer", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { playlistToDelete = null }) { Text("Annuler") } + } + ) + } + ModalDrawerSheet( modifier = Modifier.width(300.dp) ) { @@ -155,7 +183,7 @@ fun SimpleDrawerContent( if (internalExpanded) { items(internalList) { item -> val isSelected = item.path == activePath - val onPlaylist = playList.contains(item) + val onPlaylist = playlists.any { pl -> pl.songs.any { it.entry.path == item.path } } val isCurrentFavorite = favoList.contains(item) NavigationDrawerItem( @@ -166,7 +194,9 @@ fun SimpleDrawerContent( badge = { IconButton( onClick = { - sharedScreenModel.toPlaylist(item.path) + scope.launch { + sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path) + } }, modifier = Modifier.size(32.dp) ) { @@ -208,7 +238,7 @@ fun SimpleDrawerContent( if (externalExpanded) { items(externalList) { item -> val isSelected = item.path == activePath - val onPlaylist = playList.contains(item) + val onPlaylist = playlists.any { pl -> pl.songs.any { it.entry.path == item.path } } if (item.path != "") { NavigationDrawerItem( @@ -219,7 +249,9 @@ fun SimpleDrawerContent( badge = { IconButton( onClick = { - sharedScreenModel.toPlaylist(item.path) + scope.launch { + sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path) + } }, modifier = Modifier.size(32.dp) ) { @@ -254,63 +286,69 @@ fun SimpleDrawerContent( title = "Playlist", icon = Icons.AutoMirrored.Filled.PlaylistPlay, isExpanded = playListExpanded, - onToggle = { playListExpanded = !playListExpanded }, - count = playList.size + onToggle = { sharedScreenModel.togglePlaylistSection() }, + count = playlists.size ) } if (playListExpanded) { - itemsIndexed(playList) { index, item -> - if (item.path.isNotBlank()) { - val isSelected = item.path == activePath + playlists.forEach { pl -> + val isOpen = pl.name in expandedPlaylists + val activeSongs = pl.songs.mapNotNull { s -> + s.item?.let { ActivePlaylistSong(path = it.path, stanzas = s.entry.stanzas) } + } - NavigationDrawerItem( - icon = { - Text("♯", fontSize = 22.sp) - }, - label = { - DrawerItemLabel(item) - }, - badge = { - if (index > 0) { - IconButton( - onClick = { sharedScreenModel.moveToTop(index) }, - modifier = Modifier.size(30.dp) - ) { - Icon( - imageVector = Icons.Default.VerticalAlignTop, - contentDescription = "Btn align playlist drawer", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondary - ) + item(key = "pl_${pl.name}") { + DrawerPlaylistRow( + name = pl.name, + songCount = pl.songs.size, + expanded = isOpen, + onToggle = { + sharedScreenModel.togglePlaylistExpanded(pl.name) + scope.launch { + sharedScreenModel.selectPlaylist(pl.name) + } + }, + onDelete = { playlistToDelete = pl.name }, + onPlay = { + scope.launch { + sharedScreenModel.selectPlaylist(pl.name) + val firstPath = sharedScreenModel.startPlaylist(activeSongs) + if (firstPath != null) { + scope.launch { drawerState.close() } + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(firstPath) + onSongSelected(midi) } } - IconButton( - onClick = { sharedScreenModel.toPlaylist(item.path) }, - modifier = Modifier.size(30.dp) - ) { - Icon( - imageVector = Icons.Default.PlaylistRemove, - contentDescription = "Btn remove playlist drawer", - tint = if (isSelected) Color.LightGray else Color(0xFFFFD700) - ) - } - }, - selected = isSelected, - onClick = { - scope.launch { drawerState.close() } - sharedScreenModel.reset() - solfaScreenModel.loadFromFile(item.path) - onSongSelected(midi) - }, - shape = RoundedCornerShape(5.dp), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - colors = NavigationDrawerItemDefaults.colors( - selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), - selectedTextColor = MaterialTheme.colorScheme.onSecondary, - ) + } ) } + + if (isOpen) { + itemsIndexed(pl.songs, key = { index, _ -> "pls_${pl.name}_$index" }) { _, song -> + val di = song.item + DrawerPlaylistSongRow( + title = di?.title ?: song.entry.chant, + contentTitle = di?.contentTitle ?: "chant introuvable", + selected = di != null && di.path == activePath, + enabled = di != null, + stz = song.entry.stanzas, + onClick = { + if (di != null) { + scope.launch { + sharedScreenModel.selectPlaylist(pl.name) + sharedScreenModel.startPlaylist(activeSongs) + scope.launch { drawerState.close() } + sharedScreenModel.reset() + solfaScreenModel.loadFromFile(di.path) + onSongSelected(midi) + } + } + } + ) + } + } } } @@ -401,6 +439,19 @@ fun SimpleDrawerContent( } } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text("Gèrer la playlist") } }, + state = rememberTooltipState() + ) { + IconButton(onClick = { + onPlaylistSreen() + scope.launch { drawerState.close() } + }) { + Icon(Icons.AutoMirrored.Default.FeaturedPlayList, contentDescription = "Btn to manage playlist", tint = MaterialTheme.colorScheme.onSecondary) + } + } + TooltipBox( positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text("Paramètres") } }, @@ -537,4 +588,114 @@ fun CustomSwitchItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } +} +@Composable +fun DrawerPlaylistRow( + name: String, + songCount: Int, + expanded: Boolean, + onToggle: () -> Unit, + onPlay: () -> Unit, + onDelete: () -> Unit, +) { + val rotation by animateFloatAsState(if (expanded) 180f else 0f) + + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Transparent, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .clickable(onClick = onToggle) + ) { + Row( + modifier = Modifier + .heightIn(min = 48.dp) + .padding(start = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = if (expanded) "Replier $name" else "Déplier $name", + modifier = Modifier.rotate(rotation).size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (songCount <= 1) "$songCount partition" else "$songCount partitions", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = onPlay, + enabled = songCount > 0, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Lancer la playlist $name", + modifier = Modifier.size(20.dp) + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Supprimer la playlist $name", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@Composable +fun DrawerPlaylistSongRow( + title: String, + contentTitle: String, + selected: Boolean, + enabled: Boolean, + stz: List, + onClick: () -> Unit, +) { + val verses = if(stz.isEmpty()) "" else ": ${stz.joinToString(", ")}" + NavigationDrawerItem( + icon = { + Text("♪", fontSize = 20.sp) + }, + label = { + Column { + Text( + text = "$title$verses", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = contentTitle, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + selected = selected, + onClick = { if (enabled) onClick() }, + shape = RoundedCornerShape(5.dp), + modifier = Modifier.padding(start = 28.dp, end = 12.dp, top = 2.dp, bottom = 2.dp), + colors = NavigationDrawerItemDefaults.colors( + selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), + selectedTextColor = MaterialTheme.colorScheme.onSecondary + ) + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index b0344aa..64912d7 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -5,26 +5,26 @@ import androidx.compose.runtime.setValue import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.russhwolf.settings.Settings -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import mg.dot.feufaro.FileRepository -import mg.dot.feufaro.data.DrawerItem -import mg.dot.feufaro.data.GridTUOData -import mg.dot.feufaro.data.getCombinedList +import mg.dot.feufaro.data.* import mg.dot.feufaro.midi.FMediaPlayer import mg.dot.feufaro.provideSettings import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.TimeUnitObject -import mg.dot.feufaro.solfa.getAllMarker +import mg.dot.feufaro.ui.findMatchingSong +import mg.dot.feufaro.ui.parseRawTextToEntries +import mg.dot.feufaro.ui.parseVerses import mg.dot.feufaro.viewmodel.MidiMarkers import java.io.File class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel { private val _nextLabel = MutableStateFlow("Next ...") val nextLabel: StateFlow = _nextLabel.asStateFlow() + private val _prevLabel = MutableStateFlow("Prev ...") + val prevLabel: StateFlow = _prevLabel.asStateFlow() private val _loadFile = MutableStateFlow("Load ...") @@ -118,7 +118,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _silentDurationBefore.value = silent } - private val playlistFilename = "playlist.json" + private val playlistFilename = "playlists.json" private val _playlistPaths = MutableStateFlow>(emptyList()) val playlistItems: StateFlow> = combine(_playlistPaths, _drawerItems) { paths, allItems -> paths.mapNotNull { path -> allItems.find { it.path == path } } @@ -130,6 +130,355 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode paths.mapNotNull { path -> allItems.find { it.path == path } } }.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) + + val playlistNames = MutableStateFlow>(emptyList()) + val selectedPlaylist = MutableStateFlow("Dimanche Matin") + val currentPlaylistRawText = MutableStateFlow("") + + val playlistSectionExpanded = MutableStateFlow(false) + val expandedPlaylistNames = MutableStateFlow>(emptySet()) + fun togglePlaylistSection() { + playlistSectionExpanded.value = !playlistSectionExpanded.value + } + fun togglePlaylistExpanded(name: String) { + val current = expandedPlaylistNames.value + expandedPlaylistNames.value = if (name in current) current - name else current + name + } + + private val _playlistsRaw = MutableStateFlow>(emptyList()) + val playlistsUi: StateFlow> = combine(_playlistsRaw, _drawerItems) { raw, all -> + raw.map { p -> + PlaylistUi( + name = p.name, + songs = p.entries.map { e -> + val item = all.find { it.path == e.path } + ?: all.find { it.title.normalizeForSearch() == e.chant.normalizeForSearch() } + PlaylistSongUi(entry = e, item = item) + } + ) + } + }.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) + + private suspend fun refreshAllPlaylists() = withContext(Dispatchers.IO) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + if (!file.exists() || file.readText().isBlank()) { + _playlistsRaw.value = emptyList() + return@withContext + } + val data = jsonFormat.decodeFromString(file.readText()) + _playlistsRaw.value = data.playlist.flatMap { map -> + map.map { (name, songs) -> PlaylistWithSongs(name, songs) } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private val _activePlaylist = MutableStateFlow>(emptyList()) + private val _activeFilePathFlow = MutableStateFlow("") + + fun startPlaylist(songs: List): String? { + val valid = songs.filter { it.path.isNotBlank() } + _activePlaylist.value = valid + + val first = valid.firstOrNull() + if (first != null) { + if (first.stanzas.isNotEmpty()) { + _stanza.value = first.stanzas.sorted().first() + } else { + _stanza.value = 1 + } + } + + return first?.path + } + + fun clearActivePlaylist() { + _activePlaylist.value = emptyList() + } + + private fun samePath(a: String, b: String): Boolean = + a.isNotBlank() && b.isNotBlank() && (a == b || a.endsWith(b) || b.endsWith(a)) + val playlistStanzas: StateFlow> = + combine(_activePlaylist, _activeFilePathFlow, _nbStanzas) { active, path, nb -> + val entry = active.find { samePath(it.path, path) } + entry?.stanzas?.filter { it in 1..nb }?.distinct()?.sorted().orEmpty() + }.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList()) + + val allowedStanzas: StateFlow> = + combine(playlistStanzas, _nbStanzas) { restricted, nb -> + if (restricted.isEmpty()) (1..nb).toList() else restricted + }.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList()) + + fun nextAllowedStanza(current: Int): Int? = allowedStanzas.value.firstOrNull { it > current } + fun prevAllowedStanza(current: Int): Int? = allowedStanzas.value.lastOrNull { it < current } + + private val jsonFormat = Json { prettyPrint = true; ignoreUnknownKeys = true } + suspend fun loadPlaylists() { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + + if (!file.exists() || file.readText().isNotBlank() == false) { + val defaultData = PlaylistsFile( + playlist = listOf( + mapOf( + "Dimanche Matin" to listOf( + PlaylistSongEntry(1, "ews77", "assets://ews-77.txt", emptyList()), + PlaylistSongEntry(2, "ffpm77", "assets://ffpm-77.txt", listOf(1, 2, 3, 4)), + PlaylistSongEntry(2, "ffpm78", "assets://ffpm-78.txt", listOf(2)) + ) + ) + ) + ) + file.writeText(jsonFormat.encodeToString(defaultData)) + } + + val jsonString = file.readText() + if (jsonString.isNotBlank()) { + val playlistsFile = jsonFormat.decodeFromString(jsonString) + + val names = playlistsFile.playlist.flatMap { it.keys } + playlistNames.value = names + refreshAllPlaylists() + + if (names.isNotEmpty()) { + val currentSel = if (selectedPlaylist.value in names) selectedPlaylist.value else names.first() + selectPlaylist(currentSel) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + suspend fun selectPlaylist(name: String) { + selectedPlaylist.value = name + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + if (file.exists()) { + val playlistsFile = jsonFormat.decodeFromString(file.readText()) + val playlistMap = playlistsFile.playlist.find { it.containsKey(name) } + val songs = playlistMap?.get(name) ?: emptyList() + + currentPlaylistRawText.value = songs.joinToString("; ") { song -> + val stanzasStr = if (song.stanzas.isNotEmpty()) song.stanzas.joinToString(",") else "" + if (stanzasStr.isNotBlank()) "${song.chant}:$stanzasStr" else song.chant + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + suspend fun createPlaylist(newName: String) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + val currentData = if (file.exists() && file.readText().isNotBlank()) { + jsonFormat.decodeFromString(file.readText()) + } else { + PlaylistsFile(emptyList()) + } + + if (currentData.playlist.any { it.containsKey(newName) }) return + + val updatedList = currentData.playlist + mapOf(newName to emptyList()) + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + + loadPlaylists() + selectPlaylist(newName) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun deletePlaylist(playlistName: String) { + screenModelScope.launch(Dispatchers.IO) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + + if (file.exists() && file.readText().isNotBlank()) { + val currentData = jsonFormat.decodeFromString(file.readText()) + val updatedList = currentData.playlist.filterNot { it.containsKey(playlistName) } + + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + expandedPlaylistNames.value = expandedPlaylistNames.value - playlistName + + if (updatedList.isEmpty()) { + playlistNames.value = emptyList() + selectedPlaylist.value = "" + currentPlaylistRawText.value = "" + refreshAllPlaylists() + } else { + loadPlaylists() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + suspend fun renamePlaylist(oldName: String, newName: String) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + if (!file.exists() || file.readText().isBlank()) return + + val currentData = jsonFormat.decodeFromString(file.readText()) + val updatedList = currentData.playlist.map { map -> + if (map.containsKey(oldName)) { + mapOf(newName to (map[oldName] ?: emptyList())) + } else { + map + } + } + + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + expandedPlaylistNames.value = expandedPlaylistNames.value + .map { if (it == oldName) newName else it }.toSet() + loadPlaylists() + selectPlaylist(newName) + } catch (e: Exception) { + e.printStackTrace() + } + } + + suspend fun savePlaylist(playlistName: String, rawText: String) { + try { + + val entries = parseRawTextToEntries(rawText) + val songEntries = entries.mapIndexed { index, entry -> + val stanzas = parseVerses(entry.verseStr)?.toList() ?: emptyList() + + val matchedItem = findMatchingSong(entry.query, drawerItems.value) + + PlaylistSongEntry( + id = index + 1, + chant = entry.query, + path = matchedItem?.path ?: "assets://${entry.query}.txt", + stanzas = stanzas + ) + } + + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + val currentData = if (file.exists() && file.readText().isNotBlank()) { + jsonFormat.decodeFromString(file.readText()) + } else { + PlaylistsFile(emptyList()) + } + + val updatedList = currentData.playlist.map { map -> + if (map.containsKey(playlistName)) { + mapOf(playlistName to songEntries) + } else { + map + } + } + + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + refreshAllPlaylists() + currentPlaylistRawText.value = rawText + } catch (e: Exception) { + e.printStackTrace() + } + } + + data class AddToPlaylistResult(val playlistName: String, val alreadyPresent: Boolean) + + suspend fun addSongToPlaylist( + songTitle: String, + path: String, + stanzas: List = emptyList(), + ): AddToPlaylistResult? = withContext(Dispatchers.IO) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + val currentData = if (file.exists() && file.readText().isNotBlank()) { + jsonFormat.decodeFromString(file.readText()) + } else { + PlaylistsFile(emptyList()) + } + + // CORRECTION : Utiliser la playlist actuellement sélectionnée au lieu de la dernière du fichier + val targetName = selectedPlaylist.value.ifBlank { + currentData.playlist.firstNotNullOfOrNull { it.keys.firstOrNull() } ?: "Ma playlist" + } + + // Rechercher l'index de la playlist ciblée dans la liste + val mapIndex = currentData.playlist.indexOfFirst { it.containsKey(targetName) } + val songs = if (mapIndex != -1) { + currentData.playlist[mapIndex][targetName] ?: emptyList() + } else { + emptyList() + } + + // Vérifier si le chant est déjà présent dans cette playlist + if (songs.any { it.path == path }) { + return@withContext AddToPlaylistResult(targetName, alreadyPresent = true) + } + + val chantKey = drawerItems.value.find { it.path == path }?.title ?: songTitle + + val newSongs = songs + PlaylistSongEntry( + id = songs.size + 1, + chant = chantKey, + path = path, + stanzas = stanzas + ) + + val updatedMap = mapOf(targetName to newSongs) + val updatedList = if (mapIndex >= 0) { + currentData.playlist.toMutableList().also { it[mapIndex] = updatedMap } + } else { + currentData.playlist.toMutableList() + updatedMap + } + + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + playlistNames.value = updatedList.flatMap { it.keys } + selectPlaylist(targetName) + + refreshAllPlaylists() + AddToPlaylistResult(targetName, alreadyPresent = false) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun isSongInSelectedPlaylist(path: String): Boolean { + val currentName = selectedPlaylist.value + val pl = playlistsUi.value.find { it.name == currentName } + return pl?.songs?.any { it.entry.path == path || it.item?.path == path } == true + } + + suspend fun removeSongFromPlaylist(playlistName: String, path: String) = withContext(Dispatchers.IO) { + try { + val folder = fileRepository.getAppPublicFolder() + val file = File(folder, playlistFilename) + if (!file.exists()) return@withContext + val currentData = jsonFormat.decodeFromString(file.readText()) + val updatedList = currentData.playlist.map { map -> + if (map.containsKey(playlistName)) { + val songs = map[playlistName] ?: emptyList() + val filteredSongs = songs.filterNot { it.path == path } + mapOf(playlistName to filteredSongs) + } else { + map + } + } + file.writeText(jsonFormat.encodeToString(PlaylistsFile(updatedList))) + refreshAllPlaylists() + selectPlaylist(playlistName) + } catch (e: Exception) { + e.printStackTrace() + } + } fun saveItToDisk(list: List, name: String) { screenModelScope.launch { try { @@ -347,6 +696,15 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode fun setFileContent(content: String?, path: String) { _fileContent.value = content _activeFilePath.value = path + _activeFilePathFlow.value = path + + val active = _activePlaylist.value + val entry = active.find { samePath(it.path, path) } + if (entry != null && entry.stanzas.isNotEmpty()) { + _stanza.value = entry.stanzas.sorted().first() + } else if (_stanza.value == 0) { + _stanza.value = 1 + } } fun toggleQRCodeVisibility() { _isQRCodeVisible.value = !_isQRCodeVisible.value @@ -427,6 +785,38 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _mediaPlayer?.seekTo(micros / 1000) } + fun getPlaylistItemByOffset(offset: Int): DrawerItem? { + val active = _activePlaylist.value + val all = _drawerItems.value + val items = if (active.isNotEmpty()) { + active.mapNotNull { s -> all.find { it.path == s.path } } + } else { + playlistItems.value + } + val currentPath = _activeFilePathFlow.value + + if (items.isEmpty()) return null + + val currentIndex = items.indexOfFirst { item -> + item.path == currentPath || (currentPath.isNotBlank() && (item.path.endsWith(currentPath) || currentPath.endsWith(item.path))) + } + + return if (currentIndex != -1) { + val targetIndex = (currentIndex + offset).mod(items.size) + items[targetIndex] + } else { + items.firstOrNull() + } + } + + fun getNextPlaylistItemPath(): String? { + return getPlaylistItemByOffset(1)?.path + } + + fun getPrevPlaylistItemPath(): String? { + return getPlaylistItemByOffset(-1)?.path + } + private val _midiMarkersList = MutableStateFlow>(emptyList()) val midiMarkersList: StateFlow> = _midiMarkersList private val _activeIndex = MutableStateFlow(-1)