Implements persistent playlist & separate in(ex)ternal sources on drawer content

This commit is contained in:
hasinarak3@gmail.com 2026-04-27 14:41:13 +03:00
parent 41d79b3a20
commit 0df0675b89
12 changed files with 669 additions and 161 deletions

View file

@ -88,7 +88,7 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
val file = if (filePath.contains("/") ) { val file = if (filePath.contains("/") ) {
File(filePath) File(filePath)
} else { } else {
File(getAppPublicFolder(), filePath) File(getPAppPublicFolder(), filePath)
} }
try { try {
@ -104,7 +104,15 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
throw e throw e
} }
} }
private fun getAppPublicFolder(): File { override suspend fun getAppPublicFolder(): File {
val publicDocsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val appDir = File(publicDocsDir, "Feufaro")
if (!appDir.exists()) {
appDir.mkdirs()
}
return appDir
}
private fun getPAppPublicFolder(): File {
val publicDocsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) val publicDocsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val appDir = File(publicDocsDir, "Feufaro") val appDir = File(publicDocsDir, "Feufaro")
if (!appDir.exists()) { if (!appDir.exists()) {
@ -114,7 +122,7 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
} }
override fun pickSavePath(defaultName: String): String? { override fun pickSavePath(defaultName: String): String? {
return File(getAppPublicFolder(), defaultName).absolutePath return File(getPAppPublicFolder(), defaultName).absolutePath
} }
suspend fun openPdfFile(context: Context, filePath: String) { suspend fun openPdfFile(context: Context, filePath: String) {
val file = File(filePath) val file = File(filePath)

View file

@ -1,13 +1,15 @@
package mg.dot.feufaro.ui package mg.dot.feufaro.ui
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@Composable @Composable
actual fun MyVerticalScrollbar ( actual fun MyVerticalScrollbar (
scrollState: ScrollState,
modifier: Modifier, modifier: Modifier,
scrollState: ScrollState?,
lazyListState: LazyListState?,
content: @Composable () -> Unit content: @Composable () -> Unit
){ ){
content() content()

View file

@ -3,6 +3,7 @@ package mg.dot.feufaro
import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.OutputStream import java.io.OutputStream
// Définissez une expect interface. Elle spécifie le contrat de votre repository. // Définissez une expect interface. Elle spécifie le contrat de votre repository.
// Utilisez 'expect interface' car l'implémentation (actual) variera selon la plateforme. // Utilisez 'expect interface' car l'implémentation (actual) variera selon la plateforme.
@ -16,6 +17,7 @@ interface FileRepository {
//Lire le dernier dossier d'importation //Lire le dernier dossier d'importation
suspend fun saveFile(filePath: String, data: ByteArray) suspend fun saveFile(filePath: String, data: ByteArray)
suspend fun saveLocalFile(filePath: String, data: ByteArray) suspend fun saveLocalFile(filePath: String, data: ByteArray)
suspend fun getAppPublicFolder() : File
fun getFileName(shortName: String) : String fun getFileName(shortName: String) : String
// suspend fun readFileBytes(filePath: String): ByteArray // suspend fun readFileBytes(filePath: String): ByteArray
fun pickSavePath(defaultName: String): String? fun pickSavePath(defaultName: String): String?

View file

@ -67,6 +67,7 @@ object ScreenSolfa : Screen {
val gridTUOData = GridTUOData(measure, tuoList, stanza) val gridTUOData = GridTUOData(measure, tuoList, stanza)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var showContextualMenu = false
var viewportHeight by remember { mutableStateOf(0) } var viewportHeight by remember { mutableStateOf(0) }
var isScanning by remember { mutableStateOf(false) } var isScanning by remember { mutableStateOf(false) }
@ -154,7 +155,7 @@ object ScreenSolfa : Screen {
) )
Text(text = measureString) Text(text = measureString)
Text(text = "Stanza: $stanza") Text(text = "Stanza: $stanza")
ScreenTranspose.Content() //ScreenTranspose.Content()
} }
LazyVerticalGridTUO( LazyVerticalGridTUO(
gridTUOData, gridTUOData,
@ -219,7 +220,7 @@ object ScreenSolfa : Screen {
} }
} }
MyVerticalScrollbar( MyVerticalScrollbar(
scrollState, scrollState = scrollState,
modifier = Modifier.align(Alignment.CenterEnd) modifier = Modifier.align(Alignment.CenterEnd)
) {} ) {}
} }

View file

@ -4,6 +4,8 @@ import feufaro.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import mg.dot.feufaro.FileRepository
import java.io.File
data class DrawerItem( data class DrawerItem(
val id: Int, val id: Int,
@ -14,9 +16,7 @@ data class DrawerItem(
@Serializable @Serializable
data class FeuIndex( data class FeuIndex(
val internalFeu: List<FeuItem>, val internalFeu: List<FeuItem>
val externalFeu: List<FeuItem> = emptyList(),
val playlist: List<FeuItem> = emptyList()
) )
@Serializable @Serializable
@ -28,29 +28,58 @@ data class FeuItem(
) )
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
suspend fun getDynamicDrawerItems(): List<DrawerItem> { suspend fun getCombinedList(fileRepository: FileRepository): List<DrawerItem> {
return try { val finalList = mutableListOf<DrawerItem>()
try {
val bytes = Res.readBytes("files/feuList.json") val bytes = Res.readBytes("files/feuList.json")
val jsonString = bytes.decodeToString() val index = Json.decodeFromString<FeuIndex>(bytes.decodeToString())
finalList.addAll(index.internalFeu.map {
DrawerItem(it.id, it.fileName.removeSuffix(".txt"), it.title, it.path)
})
} catch (e: Exception) {
e.printStackTrace()
}
val format = Json { ignoreUnknownKeys = true } try {
val index = format.decodeFromString<FeuIndex>(jsonString) val folderPath = fileRepository.getAppPublicFolder()
val directory = File(folderPath.absolutePath)
index.internalFeu.map { item -> if (directory.exists() && directory.isDirectory) {
DrawerItem( directory.listFiles { f -> f.extension == "txt" }?.forEachIndexed { i, file ->
id = item.id, finalList.add(DrawerItem(
title = item.fileName.removeSuffix(".txt"), id = 50000 + i,
contentTitle = item.title, title = file.name.removeSuffix(".txt"),
path = item.path contentTitle = extractTitle(file),
) path = file.absolutePath
))
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("Erreur JSON : ${e.message}") e.printStackTrace()
emptyList()
} }
return finalList
} }
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()
} }
private fun extractTitle(file: File): String {
return try {
file.useLines { lines ->
val titleLine = lines.find { it.contains("|t:") }
if (titleLine != null) {
titleLine.substringAfter("|t:")
.substringBefore("|")
.trim()
} else {
file.nameWithoutExtension
}
}
} catch (e: Exception) {
file.nameWithoutExtension
}
}

View file

@ -13,6 +13,7 @@ import mg.dot.feufaro.getConfigDirectoryPath
import mg.dot.feufaro.launchFilePicker import mg.dot.feufaro.launchFilePicker
import mg.dot.feufaro.midi.MidiPitch import mg.dot.feufaro.midi.MidiPitch
import mg.dot.feufaro.midi.MidiWriterKotlin import mg.dot.feufaro.midi.MidiWriterKotlin
import java.io.File
//@todo: split voices (ffpm19/ews22) ${S:mfs} in N4:, idem ffpm-212 //@todo: split voices (ffpm19/ews22) ${S:mfs} in N4:, idem ffpm-212
//@todo: ffpm-172-2 ${O:1} non fonctionnel //@todo: ffpm-172-2 ${O:1} non fonctionnel
@ -163,10 +164,22 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
if (path != null) { if (path != null) {
screenModelScope.launch(Dispatchers.Default) { screenModelScope.launch(Dispatchers.Default) {
sharedScreenModel.reset() sharedScreenModel.reset()
parse(path)
stateSettings.saveLastUsedDir(path) stateSettings.saveLastUsedDir(path)
try {
val fileName = path.substringAfterLast(File.separatorChar)
val content = fileRepository.readFileContent(path)
fileRepository.saveLocalFile(fileName, content.encodeToByteArray())
sharedScreenModel.loadItems()
parse("${fileRepository.getAppPublicFolder()}/$fileName")
println("Fichier copié avec succès vers : Documents/Feufaro")
} catch (e: Exception) {
println("Erreur lors de la copie du fichier : ${e.message}")
}
} }
//loadSolfa()
} else { } else {
println("Pas de dichier") println("Pas de dichier")
} }

View file

@ -144,6 +144,7 @@ LaunchedEffect(isPlay, isPos) {
} }
) )
} }
val favoritePaths by sharedScreenModel.playlistItems.collectAsState()
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = {
TopAppBar( TopAppBar(
modifier = Modifier.height(55.dp).windowInsetsPadding(WindowInsets.statusBars), title = { modifier = Modifier.height(55.dp).windowInsetsPadding(WindowInsets.statusBars), title = {
@ -352,7 +353,7 @@ LaunchedEffect(isPlay, isPos) {
} else { } else {
if (filteredSongs.isNotEmpty()) { if (filteredSongs.isNotEmpty()) {
Column( Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight().align(Alignment.TopCenter) modifier = Modifier.fillMaxWidth(0.75f).align(Alignment.TopCenter)
.background(MaterialTheme.colorScheme.surface).border( .background(MaterialTheme.colorScheme.surface).border(
1.dp, 1.dp,
MaterialTheme.colorScheme.outlineVariant, MaterialTheme.colorScheme.outlineVariant,
@ -361,10 +362,23 @@ LaunchedEffect(isPlay, isPos) {
) { ) {
if (filteredSongs.isNotEmpty()) { if (filteredSongs.isNotEmpty()) {
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
itemsIndexed(filteredSongs) { index, item -> itemsIndexed(filteredSongs, key = {_, item -> item.path}) { index, item ->
val isFavorite = favoritePaths.contains(item)
ListItem( ListItem(
headlineContent = { Text(item.title) }, headlineContent = { Text(item.title) },
supportingContent = { Text(item.contentTitle, maxLines = 1) }, supportingContent = { Text(item.contentTitle, maxLines = 1) },
trailingContent = {
IconButton(
onClick = { sharedScreenModel.toggleFavorite(item.path) },
modifier = Modifier.size(30.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = if (!isFavorite) Color.LightGray else Color(0xFFFFD700)
)
}
},
modifier = Modifier.clickable { modifier = Modifier.clickable {
sharedScreenModel.updateSearchTxt("") sharedScreenModel.updateSearchTxt("")
sharedScreenModel.reset() sharedScreenModel.reset()

View file

@ -1,12 +1,14 @@
package mg.dot.feufaro.ui package mg.dot.feufaro.ui
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@Composable @Composable
expect fun MyVerticalScrollbar ( expect fun MyVerticalScrollbar (
scrollState: ScrollState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit, scrollState: ScrollState? = null,
lazyListState: LazyListState? = null,
content: @Composable () -> Unit
) )

View file

@ -1,39 +1,31 @@
package mg.dot.feufaro.ui package mg.dot.feufaro.ui
import SharedScreenModel import SharedScreenModel
import androidx.compose.foundation.layout.Box import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.automirrored.filled.EventNote
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.automirrored.filled.Note
import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.automirrored.filled.StarHalf
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Button import androidx.compose.material3.*
import androidx.compose.material3.DrawerState import androidx.compose.runtime.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.data.DrawerItem
@ -41,6 +33,7 @@ import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.solfa.Solfa import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.viewmodel.SolfaScreenModel import mg.dot.feufaro.viewmodel.SolfaScreenModel
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SimpleDrawerContent( fun SimpleDrawerContent(
items: List<DrawerItem>, items: List<DrawerItem>,
@ -56,86 +49,263 @@ fun SimpleDrawerContent(
val context = getPlatform() val context = getPlatform()
val midi = "whawyd3.mid" val midi = "whawyd3.mid"
val internalList by sharedScreenModel.internalItems.collectAsState()
val externalList by sharedScreenModel.externalItems.collectAsState()
val playList by sharedScreenModel.playlistItems.collectAsState()
val listState = rememberLazyListState()
var internalExpanded by remember { mutableStateOf(true) }
var externalExpanded by remember { mutableStateOf(false) }
var playListExpanded by remember { mutableStateOf(false) }
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.width(300.dp) modifier = Modifier.width(300.dp)
) { ) {
Box ( Column(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxSize() Box(modifier = Modifier.weight(1f).padding(0.dp, 20.dp)) {
)
{
val lazyListState = rememberLazyListState()
ScrollableDrawerContent(
lazyListState = lazyListState,
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
state = lazyListState,
){
stickyHeader {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillParentMaxWidth()
) {
Text(
"Liste des solfa disponibles",
style = MaterialTheme.typography.titleLarge
)
}
}
itemsIndexed(items){ index, item ->
val isSelected = item.path == activePath
val title = item.title
var isFfpm = false
var isEws = false
var isFF = false
if (title.startsWith("ffpm")) { MyVerticalScrollbar(
isFfpm = true lazyListState = listState,
} else if(title.startsWith("ews")) { modifier = Modifier
isEws = true .align(Alignment.CenterEnd)
} else { .fillMaxHeight()
isFF = true .padding(end = 2.dp)
){
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
stickyHeader {
DrawerHeaderSticky(
title = "Solfa disponibles",
icon = Icons.AutoMirrored.Filled.Note,
color = MaterialTheme.colorScheme.primary,
isExpanded = internalExpanded,
onToggle = { internalExpanded = !internalExpanded },
count = internalList.size
)
} }
if (internalExpanded) {
items(internalList) { item ->
val isSelected = item.path == activePath
NavigationDrawerItem( NavigationDrawerItem(
label = { label = { DrawerItemLabel(item, sharedScreenModel) },
Text(item.title) selected = isSelected,
},
icon = {
val isIcon = when {
isFfpm -> Icons.AutoMirrored.Filled.MenuBook
isEws -> Icons.Filled.MusicNote
isFF -> Icons.Filled.Book
else -> Icons.Filled.Menu
}
Icon(
isIcon,
contentDescription = "",
tint = Color.Blue
)
},
badge = {
Icon(
Icons.Filled.Menu,
contentDescription = ""
)
},
selected = if(isSelected){ true } else { false },
onClick = { onClick = {
scope.launch { scope.launch { drawerState.close() }
drawerState.close()
}
sharedScreenModel.reset() sharedScreenModel.reset()
solfaScreenModel.loadFromFile(item.path) solfaScreenModel.loadFromFile(item.path)
onSongSelected(midi) onSongSelected(midi)
} },
shape = RoundedCornerShape(5.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = Color(0, 157, 255).copy(alpha = 0.1f),
selectedTextColor = Color(0, 157, 255)
)
) )
} }
} }
stickyHeader {
DrawerHeaderSticky(
title = "Personnel",
icon = Icons.AutoMirrored.Filled.EventNote,
color = MaterialTheme.colorScheme.tertiary,
isExpanded = externalExpanded,
onToggle = { externalExpanded = !externalExpanded },
count = externalList.size
)
}
if (externalExpanded) {
items(externalList) { item ->
val isSelected = item.path == activePath
if (item.path != "") {
NavigationDrawerItem(
label = { DrawerItemLabel(item, sharedScreenModel) },
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 = Color.Blue.copy(alpha = 0.1f),
selectedTextColor = Color.Blue
)
)
}
}
}
stickyHeader {
DrawerHeaderSticky(
title = "Playlist",
icon = Icons.AutoMirrored.Filled.StarHalf,
color = MaterialTheme.colorScheme.tertiary,
isExpanded = playListExpanded,
onToggle = { playListExpanded = !playListExpanded },
count = playList.size
)
}
if (playListExpanded) {
itemsIndexed(playList) { index, item ->
if (item.path.isNotBlank()) {
val isSelected = item.path == activePath
NavigationDrawerItem(
label = {
DrawerFavorisItemLabel(item, index, isSelected, sharedScreenModel)
},
selected = isSelected,
onClick = {
scope.launch { drawerState.close() }
solfaScreenModel.loadFromFile(item.path)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = Color(0xFFFFD700).copy(alpha = 0.3f),
selectedTextColor = Color(0xFF665500),
selectedIconColor = Color(0xFF665500)
)
)
}
}
}
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = {
scope.launch { drawerState.close() }
}) {
Text("Scanner")
}
Button(onClick = {
solfaScreenModel.loadCustomFile()
scope.launch { drawerState.close() }
}) {
Text("Importer")
}
}
}
}
}
@Composable
fun DrawerAccordionSection(
title: String,
items: List<DrawerItem>,
icon: ImageVector,
iconColor: Color,
activePath: String,
scope: CoroutineScope,
solfaScreenModel: SolfaScreenModel,
onItemClick: (DrawerItem) -> Unit
) {
var expanded by remember { mutableStateOf(true) }
val rotationState by animateFloatAsState(targetValue = if (expanded) 180f else 0f)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelLarge.copy(
letterSpacing = 1.sp,
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Surface(
color = iconColor.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.extraSmall
) {
Text(
text = "${items.size}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = iconColor
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.rotate(rotationState).size(20.dp)
)
}
AnimatedVisibility(visible = expanded) {
Column {
items.forEach { item ->
val isSelected = item.path == activePath
NavigationDrawerItem(
label = {
Column {
Text(
text = item.title,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = item.contentTitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
},
selected = isSelected,
onClick = { onItemClick(item) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
shape = MaterialTheme.shapes.medium,
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = Color.Transparent
)
)
}
}
}
HorizontalDivider(
modifier = Modifier.padding(top = 8.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.CenterHorizontally)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -148,7 +318,7 @@ val midi = "whawyd3.mid"
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Button( Button(
onClick = { onClick = {
onScannerButtonClick()
} }
) { ) {
Text("Scanner") Text("Scanner")
@ -157,7 +327,7 @@ val midi = "whawyd3.mid"
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
drawerState.close() //drawerState.close()
} }
solfaScreenModel.loadCustomFile() solfaScreenModel.loadCustomFile()
} }
@ -168,6 +338,135 @@ val midi = "whawyd3.mid"
} }
} }
} }
}
@Composable
fun DrawerHeaderSticky(
title: String,
icon: ImageVector,
color: Color,
isExpanded: Boolean,
onToggle: () -> Unit,
count: Int
) {
val rotation by animateFloatAsState(if (isExpanded) 180f else 0f)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth().clickable { onToggle() }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Text(
text = "$title".uppercase(),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
color = color
)
Surface(
color = Color.Green.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.extraSmall
) {
Text(
text = "${count}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.Green
)
}
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.rotate(rotation)
)
}
}
}
@Composable
fun DrawerItemLabel(item: DrawerItem, sharedScreenModel: SharedScreenModel) {
val favoriteLists by sharedScreenModel.playlistItems.collectAsState()
val isFavorite = favoriteLists.contains(item)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = item.title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
Text(text = item.contentTitle, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
Row {
IconButton(
onClick = {
sharedScreenModel.toggleFavorite(item.path)
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PlaylistAddCircle,
contentDescription = null,
tint = if (!isFavorite) Color.LightGray else Color(0xFFFFD700),
modifier = Modifier.size(18.dp)
)
}
}
}
}
@Composable
fun DrawerFavorisItemLabel(item: DrawerItem, index: Int, isSelected: Boolean, sharedScreenModel: SharedScreenModel) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Reorder,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp).size(18.dp),
tint = Color.Gray.copy(alpha = 0.5f)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = item.contentTitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
if (index > 0) {
IconButton(
onClick = { sharedScreenModel.moveToTop(index) },
modifier = Modifier.size(30.dp)
) {
Icon(
imageVector = Icons.Default.VerticalAlignTop,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = if (isSelected) Color.White else Color.Gray
)
}
}
IconButton(
onClick = { sharedScreenModel.toggleFavorite(item.path) },
modifier = Modifier.size(30.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = if (isSelected) Color.White else Color(0xFFFFD700)
)
} }
} }
} }

View file

@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,10 +20,11 @@ import mg.dot.feufaro.DeepLinkHandler
import mg.dot.feufaro.FileRepository import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.data.DrawerItem
import mg.dot.feufaro.data.GridTUOData import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getDynamicDrawerItems import mg.dot.feufaro.data.getCombinedList
import mg.dot.feufaro.solfa.TimeUnitObject import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.midi.FMediaPlayer import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.viewmodel.MidiMarkers import mg.dot.feufaro.viewmodel.MidiMarkers
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 ...")
@ -68,13 +71,127 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
private val _drawerItems = MutableStateFlow<List<DrawerItem>>(emptyList()) private val _drawerItems = MutableStateFlow<List<DrawerItem>>(emptyList())
val drawerItems: StateFlow<List<DrawerItem>> = _drawerItems.asStateFlow() val drawerItems: StateFlow<List<DrawerItem>> = _drawerItems.asStateFlow()
val internalItems: StateFlow<List<DrawerItem>> = _drawerItems
.map { list -> list.filter { it.path.startsWith("assets://") } }
.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
val externalItems: StateFlow<List<DrawerItem>> = _drawerItems
.map { list ->
list.filter { !it.path.startsWith("assets://") }
.sortedByDescending { item: DrawerItem ->
try {
File(item.path).lastModified()
} catch (e: Exception) {
0L
}
}
}
.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
private val playlistFilename = "playlist.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 } }
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
fun savePlaylistToDisk(playlist: List<String>) {
screenModelScope.launch {
try {
val itemsJson = playlist.joinToString(
separator = "\",\"",
prefix = "[\"",
postfix = "\"]"
).replace("[\"\"]", "[]")
val finalJson = "{\"playlist\": $itemsJson}"
val data = finalJson.encodeToByteArray()
fileRepository.saveLocalFile(playlistFilename, data)
println("Playlist sauvegardée avec succès !")
} catch (e: Exception) {
println("Erreur lors de la sauvegarde : ${e.message}")
}
}
}
fun toggleFavorite(path: String) {
val current = _playlistPaths.value.toMutableList()
if (current.contains(path)) {
current.remove(path)
} else {
current.add(path)
}
_playlistPaths.value = current
savePlaylistToDisk(current)
}
fun moveUp(index: Int) {
if (index > 0) {
val current = _playlistPaths.value.toMutableList()
val item = current.removeAt(index)
current.add(index - 1, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
}
}
fun moveToTop(index: Int) {
val current = _playlistPaths.value.toMutableList()
val item = current.removeAt(index)
current.add(0, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
}
fun moveDown(index: Int) {
val current = _playlistPaths.value.toMutableList()
if (index < current.size - 1) {
val item = current.removeAt(index)
current.add(index + 1, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
}
}
fun isInPlaylist(path: String): Boolean {
return _playlistPaths.value.contains(path)
}
init { init {
loadItems() loadItems()
} }
private fun loadItems() { fun loadItems() {
screenModelScope.launch { screenModelScope.launch {
_drawerItems.value = getDynamicDrawerItems() _drawerItems.value = getCombinedList(fileRepository)
}
loadPlaylistFromDisk()
}
private fun loadPlaylistFromDisk() {
screenModelScope.launch {
try {
val publicFolder = fileRepository.getAppPublicFolder()
val playlistFilePath = "$publicFolder/$playlistFilename"
val jsonString = fileRepository.readFileContent(playlistFilePath)
if (jsonString.isNotBlank()) {
val paths = jsonString
.replace("{\"playlist\":", "")
.replace("}", "")
.replace("[", "")
.replace("]", "")
.replace("\"", "")
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
_playlistPaths.value = paths
println("Playlist chargée : ${paths.size} chants")
}
} catch (e: Exception) {
println("Erreur lors du chargement de la playlist : ${e.message}")
}
} }
} }

View file

@ -46,16 +46,25 @@ class DesktopFileRepository : FileRepository { // IMPORTS AND IMPLEMENTS THE com
override suspend fun saveFile(filePath: String, data: ByteArray) { override suspend fun saveFile(filePath: String, data: ByteArray) {
val userHome = System.getProperty("user.home") val userHome = System.getProperty("user.home")
val file = File("$userHome/$filePath") val file = File("$userHome/$filePath")
file.writeBytes(data) // Extension Kotlin très efficace file.writeBytes(data)
} }
override suspend fun saveLocalFile(filePath: String, data: ByteArray) { override suspend fun saveLocalFile(filePath: String, data: ByteArray) {
val file = if (filePath.startsWith("/")) { val file = if (filePath.startsWith("/")) {
File(filePath) // chemin absolu → utilisé directement File(filePath)
} else { } else {
File(System.getProperty("user.home"), filePath) // chemin relatif → préfixé File(getAppPublicFolder(), filePath)
} }
file.parentFile?.mkdirs() // crée les dossiers parents si nécessaire file.parentFile?.mkdirs()
file.writeBytes(data) // Extension Kotlin très efficace file.writeBytes(data)
}
override suspend fun getAppPublicFolder(): File {
val userHome = System.getProperty("user.home")
val appDir = File(userHome, "Documents/Feufaro/")
if (!appDir.exists()) {
appDir.mkdirs()
}
return appDir
} }
override fun getFileName(shortName: String): String { override fun getFileName(shortName: String): String {

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -14,15 +15,26 @@ import androidx.compose.ui.graphics.Color
@Composable @Composable
actual fun MyVerticalScrollbar ( actual fun MyVerticalScrollbar (
scrollState: ScrollState,
modifier: Modifier, modifier: Modifier,
scrollState: ScrollState?,
lazyListState: LazyListState?,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Box ( modifier = Modifier.fillMaxSize()){ Box ( modifier = Modifier.fillMaxSize()){
content()
val adapter = when {
lazyListState != null -> rememberScrollbarAdapter(lazyListState)
scrollState != null -> rememberScrollbarAdapter(scrollState)
else -> null
}
if (adapter != null) {
VerticalScrollbar( VerticalScrollbar(
adapter = rememberScrollbarAdapter(scrollState = scrollState), adapter = adapter,
modifier = modifier.fillMaxHeight(0.5f).background(Color.Green) modifier = modifier.fillMaxHeight(0.5f).background(Color.Green.copy(0.75f))
) )
//content() //content()
} }
}
} }