Implement playlist management and nav gest(Android): one-finger drag to next/prev stanza & two-finger next/prev playlist song - horizontal drag

This commit is contained in:
Hasinjato 2026-10-12 11:44:56 +03:00
parent bbcb59b7e6
commit 981e22e191
8 changed files with 2104 additions and 125 deletions

View file

@ -1,6 +1,7 @@
package mg.dot.feufaro package mg.dot.feufaro
import SharedScreenModel import SharedScreenModel
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@ -8,14 +9,13 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Check
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
import androidx.compose.material3.FilterChip import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Icon import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment 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.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.koin.koinScreenModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mg.dot.feufaro.data.GridTUOData import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.solfa.ColorPrefixB import mg.dot.feufaro.solfa.ColorPrefixB
@ -88,6 +90,16 @@ object ScreenSolfa : Screen {
} }
val activeFilePath = sharedScreenModel.activeFilePath.value 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 drawerItems by sharedScreenModel.drawerItems.collectAsState()
val altFiles = remember(activeFilePath, drawerItems) { val altFiles = remember(activeFilePath, drawerItems) {
if (activeFilePath.isBlank()) { if (activeFilePath.isBlank()) {
@ -149,37 +161,88 @@ object ScreenSolfa : Screen {
} }
) )
} }
.pointerInput(stanza, nbStanzas) { .pointerInput(stanza, allowedStanzas, activeFilePath) {
var totalDragX = 0f awaitPointerEventScope {
detectHorizontalDragGestures( while (true) {
onDragStart = { totalDragX = 0f }, val event = awaitPointerEvent()
onDragEnd = { val pressed = event.changes.filter { it.pressed }
val currentStanza = stanza.toString().toIntOrNull() ?: 1 if (pressed.isEmpty()) continue
val threshold = 70f
if (totalDragX < -threshold) { val isTwoFingers = pressed.size >= 2
// Droite -> Gauche val startX = if (isTwoFingers) (pressed[0].position.x + pressed[1].position.x) / 2f else pressed[0].position.x
if (currentStanza < nbStanzas) { val startY = if (isTwoFingers) (pressed[0].position.y + pressed[1].position.y) / 2f else pressed[0].position.y
sharedScreenModel.setStanza(currentStanza + 1)
coroutineScope.launch { var currentX = startX
scrollState.animateScrollTo(0) 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) { } catch (e: Exception) {}
// Gauche -> Droite
if (currentStanza > 1) { if (isHorizontalDrag) {
sharedScreenModel.setStanza(currentStanza - 1) val totalDragX = currentX - startX
coroutineScope.launch { val threshold = 70f
scrollState.animateScrollTo(0)
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 -> .onGloballyPositioned { coordinates ->
viewportHeight = coordinates.size.height viewportHeight = coordinates.size.height
@ -206,28 +269,134 @@ object ScreenSolfa : Screen {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
) { ) {
FlowRow( val measureString: String by sharedScreenModel.measure.collectAsState()
modifier = Modifier.fillMaxWidth() val songTitle: String by sharedScreenModel.songTitle.collectAsState()
//.windowInsetsPadding(WindowInsets.safeDrawing) val songKey: String by sharedScreenModel.songKey.collectAsState()
.padding(start = 8.dp, end = 8.dp, top = 8.dp), val favItems by sharedScreenModel.favItems.collectAsState()
horizontalArrangement = Arrangement.spacedBy(16.dp), val isFavorite = favItems.any { it.path == activeFilePath }
verticalArrangement = Arrangement.spacedBy(4.dp) val isAndroid = remember { getPlatform().name.startsWith("Android") }
var playlistMessage by remember { mutableStateOf<String?>(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 sTitlewStz = if(playlistStanzas.isNotEmpty()) "$songTitle: ${playlistStanzas.joinToString(", ")}" else songTitle
val songTitle: String by sharedScreenModel.songTitle.collectAsState() Text(
val songKey: String by sharedScreenModel.songKey.collectAsState() text = sTitlewStz,
Text(text = songTitle, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) 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(
text = songKey, text = songKey,
style = MaterialTheme.typography.bodyLarge, 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) .padding(horizontal = 4.dp)
) )
Text(text = measureString, style = MaterialTheme.typography.bodyLarge) Text(text = measureString, style = MaterialTheme.typography.bodyLarge)
Text(text = "Stanza: $stanza", 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( LazyVerticalGridTUO(
gridTUOData, gridTUOData,
gridWidthPx = gridWidthPx, gridWidthPx = gridWidthPx,
@ -316,16 +485,32 @@ object ScreenSolfa : Screen {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val nextLabel: String by sharedScreenModel.nextLabel.collectAsState() val nextLabel: String by sharedScreenModel.nextLabel.collectAsState()
val prevLabel: String by sharedScreenModel.prevLabel.collectAsState()
if (nextLabel.isNotBlank()) { MGButton(
MGButton( onClick = {
onClick = { val prevPath = sharedScreenModel.getPrevPlaylistItemPath()
solfaScreenModel.loadNextInPlaylist() if (!prevPath.isNullOrBlank()) {
}, sharedScreenModel.reset()
modifier = Modifier.height(40.dp) solfaScreenModel.loadFromFile(prevPath)
) { }
Text(nextLabel) },
} 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 @Throws(ObjectStreamException::class) // C'est une méthode de sérialisation Java, donc l'exception est nécessaire
private fun readResolve(): Any { private fun readResolve(): Any {
return this // Toujours retourner l'instance unique de ce singleton return this // Toujours retourner l'instance unique de ce singleton

View file

@ -15,7 +15,8 @@ data class DrawerItem(
val title: String, val title: String,
val contentTitle: String, val contentTitle: String,
val firstNotes: String = "", val firstNotes: String = "",
val path: String val path: String,
val totalStanzas: Int = 1
) { ) {
private data class TitleComponents( private data class TitleComponents(
val prefix: String, val prefix: String,
@ -32,7 +33,6 @@ data class DrawerItem(
val version = match.groupValues[3].ifEmpty { null } val version = match.groupValues[3].ifEmpty { null }
return TitleComponents(prefix, number, version) return TitleComponents(prefix, number, version)
} }
fun matchesQuery(query: String): Boolean { fun matchesQuery(query: String): Boolean {
if (query.isBlank()) return true if (query.isBlank()) return true
val cleanQuery = query.normalizeForSearch() val cleanQuery = query.normalizeForSearch()
@ -47,7 +47,9 @@ data class DrawerItem(
val titleComponents = parseComponents(title) val titleComponents = parseComponents(title)
if (queryComponents != null && titleComponents != null) { if (queryComponents != null && titleComponents != null) {
if (queryComponents.prefix == titleComponents.prefix) { 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<DrawerItem> {
resolveContent = resolveInternalContent resolveContent = resolveInternalContent
) )
val totalStanzas = extractTotalStanzas(content)
finalList.add( finalList.add(
DrawerItem( DrawerItem(
id = item.id, id = item.id,
title = item.fileName.removeSuffix(".txt"), title = item.fileName.removeSuffix(".txt"),
contentTitle = item.title, contentTitle = item.title,
firstNotes = notes, firstNotes = notes,
path = item.path path = item.path,
totalStanzas = totalStanzas
) )
) )
} }
@ -207,7 +211,8 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
title = file.nameWithoutExtension, title = file.nameWithoutExtension,
contentTitle = extractTitle(file), contentTitle = extractTitle(file),
firstNotes = extractFirstNotes(file, localBaseKeysWithAlt), firstNotes = extractFirstNotes(file, localBaseKeysWithAlt),
path = file.absolutePath path = file.absolutePath,
totalStanzas = extractTotalStanzas(file)
) )
) )
} }
@ -219,6 +224,28 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
return finalList 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? { private fun extractTitle(content: String): String? {
val regex = Regex("""\|t:([^|]+)""") val regex = Regex("""\|t:([^|]+)""")
return regex.find(content)?.groupValues?.get(1)?.trim() return regex.find(content)?.groupValues?.get(1)?.trim()

View file

@ -0,0 +1,24 @@
package mg.dot.feufaro.data
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistsFile(
val playlist: List<Map<String, List<PlaylistSongEntry>>>
)
@Serializable
data class PlaylistSongEntry(
val id: Int,
val chant: String,
val path: String,
val stanzas: List<Int>
)
data class PlaylistWithSongs(val name: String, val entries: List<PlaylistSongEntry>)
data class PlaylistSongUi(val entry: PlaylistSongEntry, val item: DrawerItem?)
data class PlaylistUi(val name: String, val songs: List<PlaylistSongUi>)
data class ActivePlaylistSong(val path: String, val stanzas: List<Int>)

View file

@ -274,7 +274,13 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val midiWriter = MidiWriterKotlin(fileRepository) val midiWriter = MidiWriterKotlin(fileRepository)
midiWriter.process(pitches) midiWriter.process(pitches)
midiWriter.save("whawyd3.mid") 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)
}
} }
} }

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import mg.dot.feufaro.data.ActivePlaylistSong
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -73,6 +74,7 @@ fun MainScreenWithDrawer(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showSettingsDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
var showPlaylistDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val items by sharedScreenModel.drawerItems.collectAsState() val items by sharedScreenModel.drawerItems.collectAsState()
@ -233,6 +235,9 @@ fun MainScreenWithDrawer(
onSongSelected = { newSong -> onSongSelected = { newSong ->
sharedScreenModel.loadNewSong("$midiFile") sharedScreenModel.loadNewSong("$midiFile")
}, },
onPlaylistSreen = {
showPlaylistDialog = true
},
onSettingsCheck = { onSettingsCheck = {
showSettingsDialog = true showSettingsDialog = true
}, },
@ -287,7 +292,77 @@ fun MainScreenWithDrawer(
if (showAboutDialog) { if (showAboutDialog) {
AboutDialog(onDismiss = { showAboutDialog = false }) 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 favoritePaths by sharedScreenModel.favItems.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -1170,7 +1245,7 @@ fun MainScreenWithDrawer(
} }
itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item -> itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item ->
val isFavorite = favoritePaths.contains(item) val isFavorite = favoritePaths.contains(item)
val onPlaylist = playlistPaths.contains(item) val onPlaylist = item.path in lastPlaylistPaths
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue -> confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) { if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
@ -1224,12 +1299,16 @@ fun MainScreenWithDrawer(
supportingContent = { Text(item.contentTitle, maxLines = 1) }, supportingContent = { Text(item.contentTitle, maxLines = 1) },
trailingContent = { trailingContent = {
IconButton( IconButton(
onClick = { sharedScreenModel.toPlaylist(item.path) }, onClick = {
scope.launch {
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
}
},
modifier = Modifier.size(35.dp) modifier = Modifier.size(35.dp)
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Default.PlaylistAdd, 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( tint = if (!onPlaylist) Color.LightGray else Color(
0xFFFFD700 0xFFFFD700
) )

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ListAlt
import androidx.compose.material.icons.automirrored.filled.NoteAdd import androidx.compose.material.icons.automirrored.filled.NoteAdd
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay 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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.Res
import feufaro.composeapp.generated.resources.icon import feufaro.composeapp.generated.resources.icon
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mg.dot.feufaro.data.ActivePlaylistSong
import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.data.DrawerItem
import mg.dot.feufaro.getPlatform import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.solfa.Solfa import mg.dot.feufaro.solfa.Solfa
@ -48,6 +51,7 @@ fun SimpleDrawerContent(
scope: CoroutineScope, scope: CoroutineScope,
onSongSelected: (String) -> Unit, onSongSelected: (String) -> Unit,
onSettingsCheck: () -> Unit, onSettingsCheck: () -> Unit,
onPlaylistSreen: () -> Unit,
onAboutCheck: () -> Unit, onAboutCheck: () -> Unit,
) { ) {
var searchTxt by remember { mutableStateOf("") } var searchTxt by remember { mutableStateOf("") }
@ -59,16 +63,40 @@ fun SimpleDrawerContent(
val internalList by sharedScreenModel.internalItems.collectAsState() val internalList by sharedScreenModel.internalItems.collectAsState()
val externalList by sharedScreenModel.externalItems.collectAsState() val externalList by sharedScreenModel.externalItems.collectAsState()
val playList by sharedScreenModel.playlistItems.collectAsState() val playList by sharedScreenModel.playlistItems.collectAsState()
val playlists by sharedScreenModel.playlistsUi.collectAsState()
val favoList by sharedScreenModel.favItems.collectAsState() val favoList by sharedScreenModel.favItems.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var internalExpanded by remember { mutableStateOf(false) } var internalExpanded by remember { mutableStateOf(false) }
var externalExpanded by remember { mutableStateOf(false) } var externalExpanded by remember { mutableStateOf(false) }
var playListExpanded by remember { mutableStateOf(false) }
var favoriteExpanded 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<String?>(null) }
val editMode by sharedScreenModel.modeEditor.collectAsState() val editMode by sharedScreenModel.modeEditor.collectAsState()
val chordView by sharedScreenModel.harmonyView.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( ModalDrawerSheet(
modifier = Modifier.width(300.dp) modifier = Modifier.width(300.dp)
) { ) {
@ -155,7 +183,7 @@ fun SimpleDrawerContent(
if (internalExpanded) { if (internalExpanded) {
items(internalList) { item -> items(internalList) { item ->
val isSelected = item.path == activePath 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) val isCurrentFavorite = favoList.contains(item)
NavigationDrawerItem( NavigationDrawerItem(
@ -166,7 +194,9 @@ fun SimpleDrawerContent(
badge = { badge = {
IconButton( IconButton(
onClick = { onClick = {
sharedScreenModel.toPlaylist(item.path) scope.launch {
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
}
}, },
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) { ) {
@ -208,7 +238,7 @@ fun SimpleDrawerContent(
if (externalExpanded) { if (externalExpanded) {
items(externalList) { item -> items(externalList) { item ->
val isSelected = item.path == activePath 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 != "") { if (item.path != "") {
NavigationDrawerItem( NavigationDrawerItem(
@ -219,7 +249,9 @@ fun SimpleDrawerContent(
badge = { badge = {
IconButton( IconButton(
onClick = { onClick = {
sharedScreenModel.toPlaylist(item.path) scope.launch {
sharedScreenModel.addSongToPlaylist(songTitle = item.title, path = item.path)
}
}, },
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) { ) {
@ -254,63 +286,69 @@ fun SimpleDrawerContent(
title = "Playlist", title = "Playlist",
icon = Icons.AutoMirrored.Filled.PlaylistPlay, icon = Icons.AutoMirrored.Filled.PlaylistPlay,
isExpanded = playListExpanded, isExpanded = playListExpanded,
onToggle = { playListExpanded = !playListExpanded }, onToggle = { sharedScreenModel.togglePlaylistSection() },
count = playList.size count = playlists.size
) )
} }
if (playListExpanded) { if (playListExpanded) {
itemsIndexed(playList) { index, item -> playlists.forEach { pl ->
if (item.path.isNotBlank()) { val isOpen = pl.name in expandedPlaylists
val isSelected = item.path == activePath val activeSongs = pl.songs.mapNotNull { s ->
s.item?.let { ActivePlaylistSong(path = it.path, stanzas = s.entry.stanzas) }
}
NavigationDrawerItem( item(key = "pl_${pl.name}") {
icon = { DrawerPlaylistRow(
Text("♯", fontSize = 22.sp) name = pl.name,
}, songCount = pl.songs.size,
label = { expanded = isOpen,
DrawerItemLabel(item) onToggle = {
}, sharedScreenModel.togglePlaylistExpanded(pl.name)
badge = { scope.launch {
if (index > 0) { sharedScreenModel.selectPlaylist(pl.name)
IconButton( }
onClick = { sharedScreenModel.moveToTop(index) }, },
modifier = Modifier.size(30.dp) onDelete = { playlistToDelete = pl.name },
) { onPlay = {
Icon( scope.launch {
imageVector = Icons.Default.VerticalAlignTop, sharedScreenModel.selectPlaylist(pl.name)
contentDescription = "Btn align playlist drawer", val firstPath = sharedScreenModel.startPlaylist(activeSongs)
modifier = Modifier.size(16.dp), if (firstPath != null) {
tint = MaterialTheme.colorScheme.onSecondary 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( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = { PlainTooltip { Text("Paramètres") } }, tooltip = { PlainTooltip { Text("Paramètres") } },
@ -538,3 +589,113 @@ fun CustomSwitchItem(
) )
} }
} }
@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<Int>,
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
)
)
}

View file

@ -5,26 +5,26 @@ import androidx.compose.runtime.setValue
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.serialization.json.Json
import mg.dot.feufaro.FileRepository import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.data.*
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getCombinedList
import mg.dot.feufaro.midi.FMediaPlayer import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.provideSettings import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.TimeUnitObject 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 mg.dot.feufaro.viewmodel.MidiMarkers
import java.io.File import java.io.File
class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel { class SharedScreenModel(private val fileRepository: FileRepository) : ScreenModel {
private val _nextLabel = MutableStateFlow<String>("Next ...") private val _nextLabel = MutableStateFlow<String>("Next ...")
val nextLabel: StateFlow<String> = _nextLabel.asStateFlow() val nextLabel: StateFlow<String> = _nextLabel.asStateFlow()
private val _prevLabel = MutableStateFlow<String>("Prev ...")
val prevLabel: StateFlow<String> = _prevLabel.asStateFlow()
private val _loadFile = MutableStateFlow<String>("Load ...") private val _loadFile = MutableStateFlow<String>("Load ...")
@ -118,7 +118,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_silentDurationBefore.value = silent _silentDurationBefore.value = silent
} }
private val playlistFilename = "playlist.json" private val playlistFilename = "playlists.json"
private val _playlistPaths = MutableStateFlow<List<String>>(emptyList()) private val _playlistPaths = MutableStateFlow<List<String>>(emptyList())
val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems -> val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems ->
paths.mapNotNull { path -> allItems.find { it.path == path } } 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 } } paths.mapNotNull { path -> allItems.find { it.path == path } }
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) }.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
val playlistNames = MutableStateFlow<List<String>>(emptyList())
val selectedPlaylist = MutableStateFlow<String>("Dimanche Matin")
val currentPlaylistRawText = MutableStateFlow<String>("")
val playlistSectionExpanded = MutableStateFlow(false)
val expandedPlaylistNames = MutableStateFlow<Set<String>>(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<List<PlaylistWithSongs>>(emptyList())
val playlistsUi: StateFlow<List<PlaylistUi>> = 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<PlaylistsFile>(file.readText())
_playlistsRaw.value = data.playlist.flatMap { map ->
map.map { (name, songs) -> PlaylistWithSongs(name, songs) }
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private val _activePlaylist = MutableStateFlow<List<ActivePlaylistSong>>(emptyList())
private val _activeFilePathFlow = MutableStateFlow("")
fun startPlaylist(songs: List<ActivePlaylistSong>): 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<List<Int>> =
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<List<Int>> =
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<PlaylistsFile>(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<PlaylistsFile>(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<PlaylistsFile>(file.readText())
} else {
PlaylistsFile(emptyList())
}
if (currentData.playlist.any { it.containsKey(newName) }) return
val updatedList = currentData.playlist + mapOf(newName to emptyList<PlaylistSongEntry>())
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<PlaylistsFile>(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<PlaylistsFile>(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<PlaylistsFile>(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<Int> = 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<PlaylistsFile>(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<PlaylistsFile>(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<String>, name: String) { fun saveItToDisk(list: List<String>, name: String) {
screenModelScope.launch { screenModelScope.launch {
try { try {
@ -347,6 +696,15 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun setFileContent(content: String?, path: String) { fun setFileContent(content: String?, path: String) {
_fileContent.value = content _fileContent.value = content
_activeFilePath.value = path _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() { fun toggleQRCodeVisibility() {
_isQRCodeVisible.value = !_isQRCodeVisible.value _isQRCodeVisible.value = !_isQRCodeVisible.value
@ -427,6 +785,38 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_mediaPlayer?.seekTo(micros / 1000) _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<List<MidiMarkers>>(emptyList()) private val _midiMarkersList = MutableStateFlow<List<MidiMarkers>>(emptyList())
val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList
private val _activeIndex = MutableStateFlow(-1) private val _activeIndex = MutableStateFlow(-1)