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
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
.pointerInput(stanza, allowedStanzas, activeFilePath) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) continue
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
}
}
}
} catch (e: Exception) {}
if (isHorizontalDrag) {
val totalDragX = currentX - startX
val threshold = 70f
if (totalDragX < -threshold) {
// Droite -> Gauche
if (currentStanza < nbStanzas) {
sharedScreenModel.setStanza(currentStanza + 1)
coroutineScope.launch {
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 if (totalDragX > threshold) {
// Gauche -> Droite
if (currentStanza > 1) {
sharedScreenModel.setStanza(currentStanza - 1)
} 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,27 +269,133 @@ 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()
Text(text = songTitle, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
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<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 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))
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)
)
}
}
} 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))
.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)
)
}
}
playlistMessage?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp, top = 2.dp)
)
}
LazyVerticalGridTUO(
gridTUOData,
@ -316,11 +485,28 @@ 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()
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)
) {
@ -329,7 +515,6 @@ object ScreenSolfa : Screen {
}
}
}
}
MyVerticalScrollbar(
scrollState = scrollState,
modifier = Modifier.align(Alignment.CenterEnd)
@ -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

View file

@ -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,9 +47,11 @@ data class DrawerItem(
val titleComponents = parseComponents(title)
if (queryComponents != null && titleComponents != null) {
if (queryComponents.prefix == titleComponents.prefix) {
if (queryComponents.number == titleComponents.number || titleComponents.number.startsWith(queryComponents.number)) {
return true
}
}
}
val queryLetters = query.filter { it.isLetter() }.lowercase()
val queryDigits = query.filter { it.isDigit() }
@ -176,13 +178,15 @@ suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
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<DrawerItem> {
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<DrawerItem> {
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()

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

View file

@ -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
)

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.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<String?>(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,65 +286,71 @@ 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
)
}
}
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)
)
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)
}
},
selected = isSelected,
onClick = {
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(item.path)
solfaScreenModel.loadFromFile(firstPath)
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") } },
@ -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.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<String>("Next ...")
val nextLabel: StateFlow<String> = _nextLabel.asStateFlow()
private val _prevLabel = MutableStateFlow<String>("Prev ...")
val prevLabel: StateFlow<String> = _prevLabel.asStateFlow()
private val _loadFile = MutableStateFlow<String>("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<List<String>>(emptyList())
val playlistItems: StateFlow<List<DrawerItem>> = 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<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) {
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<List<MidiMarkers>>(emptyList())
val midiMarkersList: StateFlow<List<MidiMarkers>> = _midiMarkersList
private val _activeIndex = MutableStateFlow(-1)