From ca0ac202207f78ce47218432c4c5e0bb5783629f Mon Sep 17 00:00:00 2001 From: Tej Pratap Singh Date: Mon, 8 Jun 2026 15:06:30 +0530 Subject: [PATCH] Refactor lyrics-maker compose packages --- .../compose/ProjectDetailsScreenTest.kt | 1 + .../presentation/activity/SearchActivity.kt | 4 +- .../compose/ProjectsScreenCompose.kt | 568 ------------------ .../GradientText.kt} | 13 +- .../compose/common/GradientTextPreview.kt | 10 + .../ProjectDetailsScreen.kt} | 2 +- .../presentation/compose/lyrics/DragHandle.kt | 106 ++++ .../presentation/compose/lyrics/LyricRow.kt | 57 ++ .../compose/lyrics/LyricsSelectionModels.kt | 170 ++++++ .../compose/lyrics/StartDragHandle.kt | 148 +++++ .../{ => lyrics}/SyncedLyricsSelector.kt | 413 +------------ .../compose/{ => navigation}/AppNavHost.kt | 29 +- .../presentation/compose/navigation/Screen.kt | 23 + .../compose/projects/CreateNewProjectCard.kt | 83 +++ .../projects/DeleteConfirmationDialog.kt | 58 ++ .../projects/MotionProjectUpdatedLabel.kt | 13 + .../compose/projects/ProjectCard.kt | 254 ++++++++ .../projects/ProjectThumbnailExtractor.kt | 23 + .../compose/projects/ProjectsRoute.kt | 39 ++ .../compose/projects/ProjectsScreen.kt | 164 +++++ .../compose/{ => projects}/ThumbnailCache.kt | 2 +- .../SearchScreen.kt} | 2 +- .../{ => templates}/LyricsTemplateSelector.kt | 77 +-- .../compose/templates/TemplatePreviewItem.kt | 94 +++ .../viewmodel/ProjectsViewModel.kt | 2 +- .../presentation/compose/ScreenTest.kt | 1 + 26 files changed, 1272 insertions(+), 1084 deletions(-) delete mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{GradientTextCompose.kt => common/GradientText.kt} (92%) create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientTextPreview.kt rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{ProjectDetailsCompose.kt => details/ProjectDetailsScreen.kt} (99%) create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/DragHandle.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricRow.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricsSelectionModels.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/StartDragHandle.kt rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{ => lyrics}/SyncedLyricsSelector.kt (54%) rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{ => navigation}/AppNavHost.kt (92%) create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/Screen.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/CreateNewProjectCard.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/DeleteConfirmationDialog.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/MotionProjectUpdatedLabel.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectCard.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectThumbnailExtractor.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsRoute.kt create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsScreen.kt rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{ => projects}/ThumbnailCache.kt (93%) rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{SearchLyricsCompose.kt => search/SearchScreen.kt} (99%) rename modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/{ => templates}/LyricsTemplateSelector.kt (66%) create mode 100644 modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/TemplatePreviewItem.kt diff --git a/modules/lyrics-maker/src/androidTest/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsScreenTest.kt b/modules/lyrics-maker/src/androidTest/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsScreenTest.kt index 2aa5e94f..74d9166b 100644 --- a/modules/lyrics-maker/src/androidTest/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsScreenTest.kt +++ b/modules/lyrics-maker/src/androidTest/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsScreenTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.gson.JsonArray import com.google.gson.JsonObject +import com.tejpratapsingh.lyricsmaker.presentation.compose.details.ProjectDetailsScreen import com.tejpratapsingh.motionstore.tables.MotionProject import org.junit.Assert.assertEquals import org.junit.Rule diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt index 5924a80e..2002ec43 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/activity/SearchActivity.kt @@ -28,8 +28,8 @@ import androidx.navigation.compose.rememberNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.tejpratapsingh.lyricsmaker.R import com.tejpratapsingh.lyricsmaker.asLyricsApp -import com.tejpratapsingh.lyricsmaker.presentation.compose.AppNavHost -import com.tejpratapsingh.lyricsmaker.presentation.compose.Screen +import com.tejpratapsingh.lyricsmaker.presentation.compose.navigation.AppNavHost +import com.tejpratapsingh.lyricsmaker.presentation.compose.navigation.Screen import com.tejpratapsingh.lyricsmaker.presentation.ui.theme.AnimatorTheme import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsUiState import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsViewModel diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt deleted file mode 100644 index 1b009fc9..00000000 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectsScreenCompose.kt +++ /dev/null @@ -1,568 +0,0 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose - -import android.graphics.Bitmap -import android.media.MediaMetadataRetriever -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.DoneAll -import androidx.compose.material.icons.rounded.PlayCircle -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.VideocamOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tejpratapsingh.lyricsmaker.R -import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.ProjectsViewModel -import com.tejpratapsingh.motionstore.dao.MotionProjectDao -import com.tejpratapsingh.motionstore.extensions.createProjectFile -import com.tejpratapsingh.motionstore.tables.MotionProject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.lang.Exception - -@Composable -fun ProjectsRoute( - viewModel: ProjectsViewModel, - onCreateNew: () -> Unit, - onProjectClick: (MotionProject) -> Unit, - modifier: Modifier = Modifier, -) { - val projects by viewModel.projects.collectAsStateWithLifecycle() - val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() - val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle() - - val context = LocalContext.current - - ProjectsScreen( - projects = projects, - isRefreshing = isRefreshing, - sortOrder = sortOrder, - onSortOrderChange = viewModel::updateSortOrder, - onRefresh = viewModel::refresh, - onCreateNew = onCreateNew, - onProjectClick = onProjectClick, - onDeleteProject = { project -> - viewModel.deleteProject(context, project) - }, - onShareProject = viewModel::shareProject, - onSync = viewModel::syncProject, - modifier = modifier, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProjectsScreen( - projects: List, - isRefreshing: Boolean, - sortOrder: String, - onSortOrderChange: (String) -> Unit, - onRefresh: () -> Unit, - onCreateNew: () -> Unit, - onProjectClick: (MotionProject) -> Unit, - onDeleteProject: (MotionProject) -> Unit, - onShareProject: (MotionProject) -> Unit, - onSync: (MotionProject) -> Unit, - modifier: Modifier = Modifier, -) { - var showSortMenu by remember { mutableStateOf(false) } - - Scaffold( - modifier = Modifier.fillMaxSize(), - ) { padding -> - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = onRefresh, - modifier = - Modifier - .padding(padding) - .fillMaxSize(), - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - GradientText(text = stringResource(R.string.app_name)) - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd, - ) { - Box { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .clickable { showSortMenu = true } - .padding(8.dp), - ) { - Icon( - Icons.AutoMirrored.Rounded.Sort, - contentDescription = "Sort", - ) - Text( - "Sort", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Created On") }, - onClick = { - onSortOrderChange(MotionProjectDao.COL_CREATED) - showSortMenu = false - }, - trailingIcon = { - if (sortOrder == MotionProjectDao.COL_CREATED) { - Icon(Icons.Rounded.Check, contentDescription = null) - } - }, - ) - DropdownMenuItem( - text = { Text("Updated On") }, - onClick = { - onSortOrderChange(MotionProjectDao.COL_UPDATED) - showSortMenu = false - }, - trailingIcon = { - if (sortOrder == MotionProjectDao.COL_UPDATED) { - Icon(Icons.Rounded.Check, contentDescription = null) - } - }, - ) - } - } - } - } - } - item { - CreateNewProjectCard(onClick = onCreateNew) - } - items(items = projects, key = { it.id }) { project -> - val onProjectClickState by rememberUpdatedState(onProjectClick) - val onDeleteProjectState by rememberUpdatedState(onDeleteProject) - val onShareProjectState by rememberUpdatedState(onShareProject) - val onSyncState by rememberUpdatedState(onSync) - - ProjectCard( - project = project, - onClick = { onProjectClickState(project) }, - onDelete = { onDeleteProjectState(project) }, - onShare = { onShareProjectState(project) }, - onSync = { onSyncState(project) }, - ) - } - } - } - } -} - -@Composable -private fun CreateNewProjectCard( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - onClick = onClick, - modifier = - modifier - .fillMaxWidth() - .aspectRatio(1f), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - border = - BorderStroke( - width = 2.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box( - modifier = - Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = "Create new project", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(28.dp), - ) - } - Spacer(modifier = Modifier.height(10.dp)) - Text( - text = "New Project", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) - } - } -} - -@Composable -private fun DeleteConfirmationDialog( - projectName: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - title = { - Text(text = "Delete Project") - }, - text = { - Text( - text = "\"$projectName\" will be permanently deleted. This action cannot be undone.", - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - Button( - onClick = { - onConfirm() - onDismiss() - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Delete") - } - }, - dismissButton = { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -@Composable -private fun ProjectCard( - project: MotionProject, - onClick: () -> Unit, - onDelete: () -> Unit, - onShare: () -> Unit, - onSync: () -> Unit, - modifier: Modifier = Modifier, -) { - var showDeleteDialog by remember { mutableStateOf(false) } - - val context = LocalContext.current - var thumbnail by remember(project.id, project.updated) { - mutableStateOf(ThumbnailCache.get("${project.id}_${project.updated}")) - } - - LaunchedEffect(project.id, project.updated) { - if (thumbnail == null) { - withContext(Dispatchers.IO) { - val projectFile = context.createProjectFile(project) - if (projectFile.exists()) { - val extractedBitmap = extractFirstFrame(projectFile.path) - extractedBitmap?.let { - ThumbnailCache.removeByPrefix(project.id) - ThumbnailCache.put("${project.id}_${project.updated}", it) - withContext(Dispatchers.Main) { - thumbnail = it - } - } - } - } - } - } - - if (showDeleteDialog) { - DeleteConfirmationDialog( - projectName = project.name, - onConfirm = onDelete, - onDismiss = { showDeleteDialog = false }, - ) - } - - Card( - onClick = onClick, - modifier = - modifier - .fillMaxWidth() - .aspectRatio(1f), - shape = RoundedCornerShape(16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - // Thumbnail background - if (thumbnail != null) { - Image( - bitmap = thumbnail!!.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.VideocamOff, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - ) - } - } - - // Scrim so text stays readable over any thumbnail - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Color.Black.copy( - alpha = if (thumbnail != null) 0.45f else 0f, - ), - ), - ) - - Column( - modifier = - Modifier - .fillMaxSize() - .padding(14.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - // Left Item - Box( - modifier = - Modifier - .size(40.dp) - .background( - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), - shape = RoundedCornerShape(10.dp), - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (thumbnail != null) Icons.Rounded.PlayCircle else Icons.Rounded.VideocamOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(22.dp), - ) - } - - // Right Item - Sync button - IconButton( - onClick = onSync, - modifier = Modifier.size(40.dp), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.secondary, - ), - ) { - Icon( - imageVector = if (project.syncTracker.isDirty) Icons.Rounded.Check else Icons.Rounded.DoneAll, - contentDescription = if (project.syncTracker.isDirty) "Start sync" else "Synced", - modifier = Modifier.size(22.dp), - ) - } - } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = project.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = - if (thumbnail != null) { - Color.White - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = project.updatedLabel(), - style = MaterialTheme.typography.bodySmall, - color = - if (thumbnail != null) { - Color.White.copy( - alpha = 0.75f, - ) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( - onClick = onShare, - modifier = Modifier.size(32.dp), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = - if (thumbnail != null) { - Color.White - } else { - MaterialTheme.colorScheme.primary - }, - ), - ) { - Icon( - imageVector = Icons.Rounded.Share, - contentDescription = "Share project", - modifier = Modifier.size(18.dp), - ) - } - IconButton( - onClick = { showDeleteDialog = true }, - modifier = Modifier.size(32.dp), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = "Delete project", - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - } -} - -fun extractFirstFrame(videoPath: String): Bitmap? { - val retriever = MediaMetadataRetriever() - return try { - retriever.setDataSource(videoPath) - // Request a smaller frame (e.g., 300px) and use OPTION_CLOSEST_SYNC for speed - retriever.getScaledFrameAtTime( - 0, - MediaMetadataRetriever.OPTION_CLOSEST_SYNC, - 300, - 300, - ) - } catch (e: Exception) { - null - } finally { - retriever.release() - } -} - -// Extension to format the updated timestamp -private fun MotionProject.updatedLabel(): String { - val diff = System.currentTimeMillis() - updated - return when { - diff < 60_000 -> "Just now" - diff < 3_600_000 -> "${diff / 60_000}m ago" - diff < 86_400_000 -> "${diff / 3_600_000}h ago" - else -> "${diff / 86_400_000}d ago" - } -} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/GradientTextCompose.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientText.kt similarity index 92% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/GradientTextCompose.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientText.kt index d9753612..c9b6e502 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/GradientTextCompose.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientText.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.common import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -11,17 +11,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.tejpratapsingh.lyricsmaker.presentation.ui.theme.ThemeBlue import com.tejpratapsingh.lyricsmaker.presentation.ui.theme.ThemePink @@ -84,9 +83,3 @@ fun GradientText( }, ) } - -@Preview(showBackground = true) -@Composable -fun GradientTextPreview() { - GradientText(text = "Lyrics Maker") -} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientTextPreview.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientTextPreview.kt new file mode 100644 index 00000000..ec2d1983 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/common/GradientTextPreview.kt @@ -0,0 +1,10 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +@Preview(showBackground = true) +@Composable +fun GradientTextPreview() { + GradientText(text = "Lyrics Maker") +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsCompose.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/details/ProjectDetailsScreen.kt similarity index 99% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsCompose.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/details/ProjectDetailsScreen.kt index d9264eb5..aa91ddd9 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ProjectDetailsCompose.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/details/ProjectDetailsScreen.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.details import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/DragHandle.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/DragHandle.kt new file mode 100644 index 00000000..1d4a5147 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/DragHandle.kt @@ -0,0 +1,106 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.roundToInt + +@Composable +internal fun DragHandle( + label: String, + color: Color, + autoScroll: AutoScrollState, + isBeingDragged: Boolean, + livePointerYInRoot: Float, + onDragStart: (y: Float) -> Unit, + onDrag: (y: Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + var selfTopInRoot by remember { mutableFloatStateOf(0f) } + + val visualOffset by remember { + derivedStateOf { + if (isBeingDragged) { + (livePointerYInRoot - selfTopInRoot).roundToInt() + } else { + 0 + } + } + } + + Box( + modifier = + Modifier + .zIndex(if (isBeingDragged) 1f else 0f) + .offset { IntOffset(0, visualOffset) } + .fillMaxWidth() + .onGloballyPositioned { coords -> + selfTopInRoot = coords.positionInRoot().y - visualOffset + }.pointerInput(Unit) { + detectDragGestures( + onDragStart = { localOffset -> + val startY = selfTopInRoot + localOffset.y + autoScroll.isDragging = true + autoScroll.pointerYInRoot = startY + onDragStart(startY) + }, + onDrag = { _, dragAmount -> + val newY = autoScroll.pointerYInRoot + dragAmount.y + autoScroll.pointerYInRoot = newY + onDrag(newY) + }, + onDragEnd = { + autoScroll.isDragging = false + onDragEnd() + }, + onDragCancel = { + autoScroll.isDragging = false + onDragCancel() + }, + ) + }.background(color.copy(alpha = if (isBeingDragged) 0.8f else 0.6f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.DragHandle, "Drag $label handle", tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(6.dp)) + Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricRow.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricRow.kt new file mode 100644 index 00000000..342f5683 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricRow.kt @@ -0,0 +1,57 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame +import com.tejpratapsingh.motionlib.core.provideCurrentConfig + +@Composable +internal fun LyricRow( + line: SyncedLyricFrame, + isSelected: Boolean, + backgroundColor: Color, + onLongClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .clip(MaterialTheme.shapes.medium) + .combinedClickable( + onClick = {}, + onLongClick = onLongClick, + ).background(backgroundColor) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("[${line.frame}]", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(64.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = line.text.ifEmpty { "…" }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Text( + "[${line.frame / provideCurrentConfig().fps} sec]", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(64.dp), + ) + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricsSelectionModels.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricsSelectionModels.kt new file mode 100644 index 00000000..f391ccce --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/LyricsSelectionModels.kt @@ -0,0 +1,170 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame +import kotlin.math.max +import kotlin.math.min + +data class RangeSelection( + val start: Int, + val end: Int, +) { + val minIndex get() = min(start, end) + val maxIndex get() = max(start, end) + + fun contains(index: Int) = index in minIndex..maxIndex +} + +// ─── Display list item types ────────────────────────────────────────────────── + +internal sealed interface ListItem { + data class LyricItem( + val index: Int, + val frame: SyncedLyricFrame, + ) : ListItem + + data object StartHandle : ListItem + + data object EndHandle : ListItem +} + +// ─── Auto-scroll state ──────────────────────────────────────────────────────── + +/** + * Shared mutable state that coordinates auto-scrolling between the drag handles + * and the [LazyColumn]. + * + * Each handle reports its pointer Y in root (screen) coordinates while a drag + * is in progress. A [LaunchedEffect] in [SyncedLyricsSelector] wakes up whenever + * [isDragging] becomes true, then ticks at ~60 fps and calls [scrollDeltaForTick] + * to decide how far and in which direction to scroll. + * + * Edge zone: the top and bottom [edgeFraction] of the list height trigger + * scrolling. Scroll speed ramps linearly from 0 at the zone boundary to + * [maxScrollPxPerTick] at the very edge. + */ +internal class AutoScrollState { + var isDragging by mutableStateOf(false) + var pointerYInRoot by mutableFloatStateOf(0f) + var listTopInRoot by mutableFloatStateOf(0f) + var listBottomInRoot by mutableFloatStateOf(0f) + + val edgeFraction = 0.10f + val maxScrollPxPerTick = 18f + + fun scrollDeltaForTick(): Float { + if (!isDragging) return 0f + val listHeight = listBottomInRoot - listTopInRoot + if (listHeight <= 0f) return 0f + val edgeZone = listHeight * edgeFraction + + return when { + pointerYInRoot <= listTopInRoot + edgeZone -> { + // Inside or ABOVE the top edge zone + val distFromTop = pointerYInRoot - listTopInRoot + // If distFromTop is negative, it means we're above the list; + // clamp it to 0 for max speed. + val ratio = (distFromTop / edgeZone).coerceIn(0f, 1f) + -maxScrollPxPerTick * (1f - ratio) + } + + pointerYInRoot >= listBottomInRoot - edgeZone -> { + // Inside or BELOW the bottom edge zone + val distFromBottom = listBottomInRoot - pointerYInRoot + // If distFromBottom is negative, it means we're below the list; + // clamp it to 0 for max speed. + val ratio = (distFromBottom / edgeZone).coerceIn(0f, 1f) + maxScrollPxPerTick * (1f - ratio) + } + + else -> { + 0f + } + } + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +internal fun initialEndIndex( + lyrics: List, + fps: Int, + targetSeconds: Int = 60, +): Int { + if (lyrics.isEmpty()) return 0 + val targetFrames = targetSeconds * fps + val idx = lyrics.indexOfLast { it.frame <= targetFrames } + return if (idx < 0) 0 else idx +} + +/** + * Maps a screen-space Y coordinate [pointerYInRoot] to a lyric index. + * It searches the [LazyColumn] visible items to find which one is closest to the pointer. + */ +internal fun findLyricIndexAt( + listState: LazyListState, + listTopInRoot: Float, + pointerYInRoot: Float, +): Int? { + val visibleItems = listState.layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return null + + // Convert screen pointer Y to list-relative Y. + val relativeY = pointerYInRoot - listTopInRoot + + // If pointer is above the list, return the first lyric (0) + if (relativeY < 0) return 0 + // If pointer is below the list, return the last lyric (total items - 1) + // Note: This is an approximation since handles are also items, but clamping in + // the caller will handle the exact lyrics count correctly. + if (relativeY > listState.layoutInfo.viewportSize.height) { + return listState.layoutInfo.totalItemsCount - 1 + } + + // Filter to just lyric items + val lyricItems = visibleItems.filter { it.key.toString().startsWith("lyric_") } + if (lyricItems.isEmpty()) return null + + // Find the lyric item that contains the relativeY, or the closest one. + var closestIndex: Int? = null + var minDistance = Float.MAX_VALUE + + for (item in lyricItems) { + val itemTop = item.offset.toFloat() + val itemBottom = (item.offset + item.size).toFloat() + val itemCenter = (itemTop + itemBottom) / 2f + + if (relativeY >= itemTop && relativeY <= itemBottom) { + return item.key + .toString() + .removePrefix("lyric_") + .toInt() + } + + val distance = kotlin.math.abs(relativeY - itemCenter) + if (distance < minDistance) { + minDistance = distance + closestIndex = + item.key + .toString() + .removePrefix("lyric_") + .toInt() + } + } + + return closestIndex +} + +internal fun formatDuration( + frames: Int, + fps: Int, +): String { + val totalSecs = (frames / fps) + val m = totalSecs / 60 + val s = totalSecs % 60 + return "$m:${s.toString().padStart(2, '0')}" +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/StartDragHandle.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/StartDragHandle.kt new file mode 100644 index 00000000..93fbecdb --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/StartDragHandle.kt @@ -0,0 +1,148 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.roundToInt + +@Composable +internal fun StartDragHandle( + color: Color, + autoScroll: AutoScrollState, + moveMode: Boolean, + isBeingDragged: Boolean, + livePointerYInRoot: Float, + onMoveModeToggle: () -> Unit, + onDragStart: (y: Float) -> Unit, + onDrag: (y: Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + var selfTopInRoot by remember { mutableFloatStateOf(0f) } + + val visualOffset by remember { + derivedStateOf { + if (isBeingDragged) { + // The handle should be centered under livePointerYInRoot. + // We calculate how much to offset it from its "natural" position in the list. + (livePointerYInRoot - selfTopInRoot).roundToInt() + } else { + 0 + } + } + } + + val bgAlpha = + when { + isBeingDragged && moveMode -> 0.8f + isBeingDragged -> 0.7f + moveMode -> 0.6f + else -> 0.5f + } + + Row( + modifier = + Modifier + .zIndex(if (isBeingDragged) 1f else 0f) + .offset { IntOffset(0, visualOffset) } + .fillMaxWidth() + .onGloballyPositioned { coords -> + // We only update selfTopInRoot when NOT dragging to avoid feedback loops, + // OR we calculate natural position by subtracting current visualOffset. + selfTopInRoot = coords.positionInRoot().y - visualOffset + }.background(color.copy(alpha = bgAlpha)) + .padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton( + onClick = onMoveModeToggle, + colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White), + ) { + Icon( + imageVector = if (moveMode) Icons.Default.OpenWith else Icons.Default.LockOpen, + contentDescription = + if (moveMode) { + "Move range (tap to resize only)" + } else { + "Resize start (tap to move whole range)" + }, + modifier = Modifier.size(18.dp), + ) + } + + Row( + modifier = + Modifier + .weight(1f) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { localOffset -> + val startY = selfTopInRoot + localOffset.y + autoScroll.isDragging = true + autoScroll.pointerYInRoot = startY + onDragStart(startY) + }, + onDrag = { _, dragAmount -> + val newY = autoScroll.pointerYInRoot + dragAmount.y + autoScroll.pointerYInRoot = newY + onDrag(newY) + }, + onDragEnd = { + autoScroll.isDragging = false + onDragEnd() + }, + onDragCancel = { + autoScroll.isDragging = false + onDragCancel() + }, + ) + }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = if (moveMode) "START · drag moves range" else "START", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Spacer(Modifier.width(6.dp)) + Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SyncedLyricsSelector.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/SyncedLyricsSelector.kt similarity index 54% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SyncedLyricsSelector.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/SyncedLyricsSelector.kt index ed6ed5e7..feeeab24 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SyncedLyricsSelector.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/lyrics/SyncedLyricsSelector.kt @@ -1,8 +1,5 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,27 +10,21 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.NavigateNext -import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface @@ -50,192 +41,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame import com.tejpratapsingh.lyricsmaker.presentation.ui.theme.ThemeBlue import com.tejpratapsingh.lyricsmaker.presentation.ui.theme.ThemePink import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsViewModel import com.tejpratapsingh.motionlib.core.provideCurrentConfig -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlin.math.max import kotlin.math.min -import kotlin.math.roundToInt - -// ─── Domain model ───────────────────────────────────────────────────────────── - -data class RangeSelection( - val start: Int, - val end: Int, -) { - val minIndex get() = min(start, end) - val maxIndex get() = max(start, end) - - fun contains(index: Int) = index in minIndex..maxIndex -} - -// ─── Display list item types ────────────────────────────────────────────────── - -private sealed interface ListItem { - data class LyricItem( - val index: Int, - val frame: SyncedLyricFrame, - ) : ListItem - - data object StartHandle : ListItem - - data object EndHandle : ListItem -} - -// ─── Auto-scroll state ──────────────────────────────────────────────────────── - -/** - * Shared mutable state that coordinates auto-scrolling between the drag handles - * and the [LazyColumn]. - * - * Each handle reports its pointer Y in root (screen) coordinates while a drag - * is in progress. A [LaunchedEffect] in [SyncedLyricsSelector] wakes up whenever - * [isDragging] becomes true, then ticks at ~60 fps and calls [scrollDeltaForTick] - * to decide how far and in which direction to scroll. - * - * Edge zone: the top and bottom [edgeFraction] of the list height trigger - * scrolling. Scroll speed ramps linearly from 0 at the zone boundary to - * [maxScrollPxPerTick] at the very edge. - */ -private class AutoScrollState { - var isDragging by mutableStateOf(false) - var pointerYInRoot by mutableFloatStateOf(0f) - var listTopInRoot by mutableFloatStateOf(0f) - var listBottomInRoot by mutableFloatStateOf(0f) - - val edgeFraction = 0.10f - val maxScrollPxPerTick = 18f - - fun scrollDeltaForTick(): Float { - if (!isDragging) return 0f - val listHeight = listBottomInRoot - listTopInRoot - if (listHeight <= 0f) return 0f - val edgeZone = listHeight * edgeFraction - - return when { - pointerYInRoot <= listTopInRoot + edgeZone -> { - // Inside or ABOVE the top edge zone - val distFromTop = pointerYInRoot - listTopInRoot - // If distFromTop is negative, it means we're above the list; - // clamp it to 0 for max speed. - val ratio = (distFromTop / edgeZone).coerceIn(0f, 1f) - -maxScrollPxPerTick * (1f - ratio) - } - - pointerYInRoot >= listBottomInRoot - edgeZone -> { - // Inside or BELOW the bottom edge zone - val distFromBottom = listBottomInRoot - pointerYInRoot - // If distFromBottom is negative, it means we're below the list; - // clamp it to 0 for max speed. - val ratio = (distFromBottom / edgeZone).coerceIn(0f, 1f) - maxScrollPxPerTick * (1f - ratio) - } - - else -> { - 0f - } - } - } -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -private fun initialEndIndex( - lyrics: List, - fps: Int, - targetSeconds: Int = 60, -): Int { - if (lyrics.isEmpty()) return 0 - val targetFrames = targetSeconds * fps - val idx = lyrics.indexOfLast { it.frame <= targetFrames } - return if (idx < 0) 0 else idx -} - -/** - * Maps a screen-space Y coordinate [pointerYInRoot] to a lyric index. - * It searches the [LazyColumn] visible items to find which one is closest to the pointer. - */ -private fun findLyricIndexAt( - listState: LazyListState, - listTopInRoot: Float, - pointerYInRoot: Float, -): Int? { - val visibleItems = listState.layoutInfo.visibleItemsInfo - if (visibleItems.isEmpty()) return null - - // Convert screen pointer Y to list-relative Y. - val relativeY = pointerYInRoot - listTopInRoot - - // If pointer is above the list, return the first lyric (0) - if (relativeY < 0) return 0 - // If pointer is below the list, return the last lyric (total items - 1) - // Note: This is an approximation since handles are also items, but clamping in - // the caller will handle the exact lyrics count correctly. - if (relativeY > listState.layoutInfo.viewportSize.height) { - return listState.layoutInfo.totalItemsCount - 1 - } - - // Filter to just lyric items - val lyricItems = visibleItems.filter { it.key.toString().startsWith("lyric_") } - if (lyricItems.isEmpty()) return null - - // Find the lyric item that contains the relativeY, or the closest one. - var closestIndex: Int? = null - var minDistance = Float.MAX_VALUE - - for (item in lyricItems) { - val itemTop = item.offset.toFloat() - val itemBottom = (item.offset + item.size).toFloat() - val itemCenter = (itemTop + itemBottom) / 2f - - if (relativeY >= itemTop && relativeY <= itemBottom) { - return item.key - .toString() - .removePrefix("lyric_") - .toInt() - } - - val distance = kotlin.math.abs(relativeY - itemCenter) - if (distance < minDistance) { - minDistance = distance - closestIndex = - item.key - .toString() - .removePrefix("lyric_") - .toInt() - } - } - - return closestIndex -} - -private fun formatDuration( - frames: Int, - fps: Int, -): String { - val totalSecs = (frames / fps) - val m = totalSecs / 60 - val s = totalSecs % 60 - return "$m:${s.toString().padStart(2, '0')}" -} - -// ─── Main composable ────────────────────────────────────────────────────────── +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive @Composable fun SyncedLyricsSelector( @@ -566,231 +387,3 @@ fun SyncedLyricsSelector( } } } - -// ─── Handle composables ─────────────────────────────────────────────────────── - -/** - * Start handle with move-mode toggle. - */ -@Composable -private fun StartDragHandle( - color: Color, - autoScroll: AutoScrollState, - moveMode: Boolean, - isBeingDragged: Boolean, - livePointerYInRoot: Float, - onMoveModeToggle: () -> Unit, - onDragStart: (y: Float) -> Unit, - onDrag: (y: Float) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, -) { - var selfTopInRoot by remember { mutableFloatStateOf(0f) } - - val visualOffset by remember { - derivedStateOf { - if (isBeingDragged) { - // The handle should be centered under livePointerYInRoot. - // We calculate how much to offset it from its "natural" position in the list. - (livePointerYInRoot - selfTopInRoot).roundToInt() - } else { - 0 - } - } - } - - val bgAlpha = - when { - isBeingDragged && moveMode -> 0.8f - isBeingDragged -> 0.7f - moveMode -> 0.6f - else -> 0.5f - } - - Row( - modifier = - Modifier - .zIndex(if (isBeingDragged) 1f else 0f) - .offset { IntOffset(0, visualOffset) } - .fillMaxWidth() - .onGloballyPositioned { coords -> - // We only update selfTopInRoot when NOT dragging to avoid feedback loops, - // OR we calculate natural position by subtracting current visualOffset. - selfTopInRoot = coords.positionInRoot().y - visualOffset - }.background(color.copy(alpha = bgAlpha)) - .padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - IconButton( - onClick = onMoveModeToggle, - colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White), - ) { - Icon( - imageVector = if (moveMode) Icons.Default.OpenWith else Icons.Default.LockOpen, - contentDescription = - if (moveMode) { - "Move range (tap to resize only)" - } else { - "Resize start (tap to move whole range)" - }, - modifier = Modifier.size(18.dp), - ) - } - - Row( - modifier = - Modifier - .weight(1f) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { localOffset -> - val startY = selfTopInRoot + localOffset.y - autoScroll.isDragging = true - autoScroll.pointerYInRoot = startY - onDragStart(startY) - }, - onDrag = { _, dragAmount -> - val newY = autoScroll.pointerYInRoot + dragAmount.y - autoScroll.pointerYInRoot = newY - onDrag(newY) - }, - onDragEnd = { - autoScroll.isDragging = false - onDragEnd() - }, - onDragCancel = { - autoScroll.isDragging = false - onDragCancel() - }, - ) - }.padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = if (moveMode) "START · drag moves range" else "START", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = Color.White, - ) - Spacer(Modifier.width(6.dp)) - Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) - } - } -} - -/** - * Generic draggable separator (END handle). - */ -@Composable -private fun DragHandle( - label: String, - color: Color, - autoScroll: AutoScrollState, - isBeingDragged: Boolean, - livePointerYInRoot: Float, - onDragStart: (y: Float) -> Unit, - onDrag: (y: Float) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, -) { - var selfTopInRoot by remember { mutableFloatStateOf(0f) } - - val visualOffset by remember { - derivedStateOf { - if (isBeingDragged) { - (livePointerYInRoot - selfTopInRoot).roundToInt() - } else { - 0 - } - } - } - - Box( - modifier = - Modifier - .zIndex(if (isBeingDragged) 1f else 0f) - .offset { IntOffset(0, visualOffset) } - .fillMaxWidth() - .onGloballyPositioned { coords -> - selfTopInRoot = coords.positionInRoot().y - visualOffset - }.pointerInput(Unit) { - detectDragGestures( - onDragStart = { localOffset -> - val startY = selfTopInRoot + localOffset.y - autoScroll.isDragging = true - autoScroll.pointerYInRoot = startY - onDragStart(startY) - }, - onDrag = { _, dragAmount -> - val newY = autoScroll.pointerYInRoot + dragAmount.y - autoScroll.pointerYInRoot = newY - onDrag(newY) - }, - onDragEnd = { - autoScroll.isDragging = false - onDragEnd() - }, - onDragCancel = { - autoScroll.isDragging = false - onDragCancel() - }, - ) - }.background(color.copy(alpha = if (isBeingDragged) 0.8f else 0.6f)) - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon(Icons.Default.DragHandle, "Drag $label handle", tint = Color.White, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text(label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) - Spacer(Modifier.width(6.dp)) - Icon(Icons.Default.DragHandle, null, tint = Color.White, modifier = Modifier.size(18.dp)) - } - } -} - -// ─── Lyric row ──────────────────────────────────────────────────────────────── - -@Composable -private fun LyricRow( - line: SyncedLyricFrame, - isSelected: Boolean, - backgroundColor: Color, - onLongClick: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .clip(MaterialTheme.shapes.medium) - .combinedClickable( - onClick = {}, - onLongClick = onLongClick, - ).background(backgroundColor) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text("[${line.frame}]", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(64.dp)) - Spacer(Modifier.width(8.dp)) - Text( - text = line.text.ifEmpty { "…" }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - Text( - "[${line.frame / provideCurrentConfig().fps} sec]", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.width(64.dp), - ) - } -} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/AppNavHost.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/AppNavHost.kt similarity index 92% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/AppNavHost.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/AppNavHost.kt index b8227486..34530504 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/AppNavHost.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/AppNavHost.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -10,6 +10,11 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import com.tejpratapsingh.lyricsmaker.asLyricsApp import com.tejpratapsingh.lyricsmaker.domain.ensureArrayList +import com.tejpratapsingh.lyricsmaker.presentation.compose.details.ProjectDetailsScreen +import com.tejpratapsingh.lyricsmaker.presentation.compose.lyrics.SyncedLyricsSelector +import com.tejpratapsingh.lyricsmaker.presentation.compose.projects.ProjectsRoute +import com.tejpratapsingh.lyricsmaker.presentation.compose.search.SearchScreen +import com.tejpratapsingh.lyricsmaker.presentation.compose.templates.LyricsTemplateSelector import com.tejpratapsingh.lyricsmaker.presentation.motion.extractLyricsTemplateData import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsViewModel import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.ProjectsViewModel @@ -20,28 +25,6 @@ import com.tejpratapsingh.motionlib.core.extensions.md5 import com.tejpratapsingh.motionlib.templates.sdui.MotionTemplateSDUIProvider import com.tejpratapsingh.motionstore.tables.MotionProject -sealed class Screen( - val route: String, -) { - object Projects : Screen("projects") - - object Search : Screen("search") - - object Lyrics : Screen("lyrics") - - object TemplateSelector : Screen("template_selector/{projectId}") { - fun createRoute(projectId: String) = "template_selector/$projectId" - } - - object ProjectDetails : Screen("project_details/{projectId}") { - fun createRoute(projectId: String) = "project_details/$projectId" - } - - object VideoEditor : Screen("video_editor/{projectId}") { - fun createRoute(projectId: String) = "video_editor/$projectId" - } -} - @Composable fun AppNavHost( navController: NavHostController, diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/Screen.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/Screen.kt new file mode 100644 index 00000000..7a93c3db --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/navigation/Screen.kt @@ -0,0 +1,23 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.navigation + +sealed class Screen( + val route: String, +) { + object Projects : Screen("projects") + + object Search : Screen("search") + + object Lyrics : Screen("lyrics") + + object TemplateSelector : Screen("template_selector/{projectId}") { + fun createRoute(projectId: String) = "template_selector/$projectId" + } + + object ProjectDetails : Screen("project_details/{projectId}") { + fun createRoute(projectId: String) = "project_details/$projectId" + } + + object VideoEditor : Screen("video_editor/{projectId}") { + fun createRoute(projectId: String) = "video_editor/$projectId" + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/CreateNewProjectCard.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/CreateNewProjectCard.kt new file mode 100644 index 00000000..628b4ad2 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/CreateNewProjectCard.kt @@ -0,0 +1,83 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +internal fun CreateNewProjectCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onClick, + modifier = + modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + border = + BorderStroke( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = + Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Create new project", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "New Project", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/DeleteConfirmationDialog.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/DeleteConfirmationDialog.kt new file mode 100644 index 00000000..66d3b416 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/DeleteConfirmationDialog.kt @@ -0,0 +1,58 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +internal fun DeleteConfirmationDialog( + projectName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text(text = "Delete Project") + }, + text = { + Text( + text = "\"$projectName\" will be permanently deleted. This action cannot be undone.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + Button( + onClick = { + onConfirm() + onDismiss() + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Delete") + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/MotionProjectUpdatedLabel.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/MotionProjectUpdatedLabel.kt new file mode 100644 index 00000000..c6f5c81f --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/MotionProjectUpdatedLabel.kt @@ -0,0 +1,13 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import com.tejpratapsingh.motionstore.tables.MotionProject + +internal fun MotionProject.updatedLabel(): String { + val diff = System.currentTimeMillis() - updated + return when { + diff < 60_000 -> "Just now" + diff < 3_600_000 -> "${diff / 60_000}m ago" + diff < 86_400_000 -> "${diff / 3_600_000}h ago" + else -> "${diff / 86_400_000}d ago" + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectCard.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectCard.kt new file mode 100644 index 00000000..fff37641 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectCard.kt @@ -0,0 +1,254 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.PlayCircle +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.VideocamOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tejpratapsingh.motionstore.extensions.createProjectFile +import com.tejpratapsingh.motionstore.tables.MotionProject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun ProjectCard( + project: MotionProject, + onClick: () -> Unit, + onDelete: () -> Unit, + onShare: () -> Unit, + onSync: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + var thumbnail by remember(project.id, project.updated) { + mutableStateOf(ThumbnailCache.get("${project.id}_${project.updated}")) + } + + LaunchedEffect(project.id, project.updated) { + if (thumbnail == null) { + withContext(Dispatchers.IO) { + val projectFile = context.createProjectFile(project) + if (projectFile.exists()) { + val extractedBitmap = extractFirstFrame(projectFile.path) + extractedBitmap?.let { + ThumbnailCache.removeByPrefix(project.id) + ThumbnailCache.put("${project.id}_${project.updated}", it) + withContext(Dispatchers.Main) { + thumbnail = it + } + } + } + } + } + } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + projectName = project.name, + onConfirm = onDelete, + onDismiss = { showDeleteDialog = false }, + ) + } + + Card( + onClick = onClick, + modifier = + modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Box(modifier = Modifier.fillMaxSize()) { + // Thumbnail background + if (thumbnail != null) { + Image( + bitmap = thumbnail!!.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.VideocamOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + ) + } + } + + // Scrim so text stays readable over any thumbnail + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Color.Black.copy( + alpha = if (thumbnail != null) 0.45f else 0f, + ), + ), + ) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Left Item + Box( + modifier = + Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + shape = RoundedCornerShape(10.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (thumbnail != null) Icons.Rounded.PlayCircle else Icons.Rounded.VideocamOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(22.dp), + ) + } + + // Right Item - Sync button + IconButton( + onClick = onSync, + modifier = Modifier.size(40.dp), + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.secondary, + ), + ) { + Icon( + imageVector = if (project.syncTracker.isDirty) Icons.Rounded.Check else Icons.Rounded.DoneAll, + contentDescription = if (project.syncTracker.isDirty) "Start sync" else "Synced", + modifier = Modifier.size(22.dp), + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = project.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = + if (thumbnail != null) { + Color.White + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = project.updatedLabel(), + style = MaterialTheme.typography.bodySmall, + color = + if (thumbnail != null) { + Color.White.copy( + alpha = 0.75f, + ) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = onShare, + modifier = Modifier.size(32.dp), + colors = + IconButtonDefaults.iconButtonColors( + contentColor = + if (thumbnail != null) { + Color.White + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = "Share project", + modifier = Modifier.size(18.dp), + ) + } + IconButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(32.dp), + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "Delete project", + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectThumbnailExtractor.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectThumbnailExtractor.kt new file mode 100644 index 00000000..acf2db91 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectThumbnailExtractor.kt @@ -0,0 +1,23 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import java.lang.Exception + +fun extractFirstFrame(videoPath: String): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(videoPath) + // Request a smaller frame (e.g., 300px) and use OPTION_CLOSEST_SYNC for speed + retriever.getScaledFrameAtTime( + 0, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + 300, + 300, + ) + } catch (e: Exception) { + null + } finally { + retriever.release() + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsRoute.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsRoute.kt new file mode 100644 index 00000000..180382c5 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsRoute.kt @@ -0,0 +1,39 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.ProjectsViewModel +import com.tejpratapsingh.motionstore.tables.MotionProject + +@Composable +fun ProjectsRoute( + viewModel: ProjectsViewModel, + onCreateNew: () -> Unit, + onProjectClick: (MotionProject) -> Unit, + modifier: Modifier = Modifier, +) { + val projects by viewModel.projects.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle() + + val context = LocalContext.current + + ProjectsScreen( + projects = projects, + isRefreshing = isRefreshing, + sortOrder = sortOrder, + onSortOrderChange = viewModel::updateSortOrder, + onRefresh = viewModel::refresh, + onCreateNew = onCreateNew, + onProjectClick = onProjectClick, + onDeleteProject = { project -> + viewModel.deleteProject(context, project) + }, + onShareProject = viewModel::shareProject, + onSync = viewModel::syncProject, + modifier = modifier, + ) +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsScreen.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsScreen.kt new file mode 100644 index 00000000..8035b181 --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ProjectsScreen.kt @@ -0,0 +1,164 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tejpratapsingh.lyricsmaker.R +import com.tejpratapsingh.lyricsmaker.presentation.compose.common.GradientText +import com.tejpratapsingh.motionstore.dao.MotionProjectDao +import com.tejpratapsingh.motionstore.tables.MotionProject + +@Composable +fun ProjectsScreen( + projects: List, + isRefreshing: Boolean, + sortOrder: String, + onSortOrderChange: (String) -> Unit, + onRefresh: () -> Unit, + onCreateNew: () -> Unit, + onProjectClick: (MotionProject) -> Unit, + onDeleteProject: (MotionProject) -> Unit, + onShareProject: (MotionProject) -> Unit, + onSync: (MotionProject) -> Unit, + modifier: Modifier = Modifier, +) { + var showSortMenu by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GradientText(text = stringResource(R.string.app_name)) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd, + ) { + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .clickable { showSortMenu = true } + .padding(8.dp), + ) { + Icon( + Icons.AutoMirrored.Rounded.Sort, + contentDescription = "Sort", + ) + Text( + "Sort", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Created On") }, + onClick = { + onSortOrderChange(MotionProjectDao.COL_CREATED) + showSortMenu = false + }, + trailingIcon = { + if (sortOrder == MotionProjectDao.COL_CREATED) { + Icon(Icons.Rounded.Check, contentDescription = null) + } + }, + ) + DropdownMenuItem( + text = { Text("Updated On") }, + onClick = { + onSortOrderChange(MotionProjectDao.COL_UPDATED) + showSortMenu = false + }, + trailingIcon = { + if (sortOrder == MotionProjectDao.COL_UPDATED) { + Icon(Icons.Rounded.Check, contentDescription = null) + } + }, + ) + } + } + } + } + } + item { + CreateNewProjectCard(onClick = onCreateNew) + } + items(items = projects, key = { it.id }) { project -> + val onProjectClickState by rememberUpdatedState(onProjectClick) + val onDeleteProjectState by rememberUpdatedState(onDeleteProject) + val onShareProjectState by rememberUpdatedState(onShareProject) + val onSyncState by rememberUpdatedState(onSync) + + ProjectCard( + project = project, + onClick = { onProjectClickState(project) }, + onDelete = { onDeleteProjectState(project) }, + onShare = { onShareProjectState(project) }, + onSync = { onSyncState(project) }, + ) + } + } + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ThumbnailCache.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ThumbnailCache.kt similarity index 93% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ThumbnailCache.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ThumbnailCache.kt index 6e10228e..7f248b7e 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ThumbnailCache.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/projects/ThumbnailCache.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.projects import android.graphics.Bitmap import android.util.LruCache diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SearchLyricsCompose.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/search/SearchScreen.kt similarity index 99% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SearchLyricsCompose.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/search/SearchScreen.kt index d5cf38f6..ac272f51 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/SearchLyricsCompose.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/LyricsTemplateSelector.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/LyricsTemplateSelector.kt similarity index 66% rename from modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/LyricsTemplateSelector.kt rename to modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/LyricsTemplateSelector.kt index 4eb792d6..f1904b36 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/LyricsTemplateSelector.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/LyricsTemplateSelector.kt @@ -1,4 +1,4 @@ -package com.tejpratapsingh.lyricsmaker.presentation.compose +package com.tejpratapsingh.lyricsmaker.presentation.compose.templates import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -11,36 +11,29 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.tejpratapsingh.lyricsmaker.presentation.motion.createLyricsVideoPreviewProducer import com.tejpratapsingh.lyricsmaker.presentation.templates.LyricsTemplateRegistry -import com.tejpratapsingh.motionlib.core.motion.MotionVideoProducer import com.tejpratapsingh.motionlib.templates.model.MotionTemplate -import com.tejpratapsingh.motionlib.ui.custom.video.MotionVideoPlayerCompose import com.tejpratapsingh.motionstore.tables.MotionProject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun LyricsTemplateSelector( @@ -58,10 +51,14 @@ fun LyricsTemplateSelector( .fillMaxSize() .background(Color.Black), ) { - // Full Screen Vertical Pager - VerticalPager( + // Horizontal Pager with partial visibility + HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 48.dp), + pageSpacing = 16.dp, + beyondViewportPageCount = 1, + flingBehavior = PagerDefaults.flingBehavior(state = pagerState), ) { page -> TemplatePreviewItem( project = project, @@ -139,57 +136,3 @@ fun LyricsTemplateSelector( } } } - -@Composable -private fun TemplatePreviewItem( - project: MotionProject, - template: MotionTemplate, - isActive: Boolean, -) { - val context = LocalContext.current - - val motionVideoProducer by - produceState(initialValue = null, project.id, template.name) { - value = - withContext(Dispatchers.Default) { - createLyricsVideoPreviewProducer(context, project, template) - } - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - motionVideoProducer?.let { - MotionVideoPlayerCompose( - motionVideoProducer = it, - modifier = Modifier.fillMaxSize(), - isPlaying = isActive, - showControls = false, - ) - } ?: run { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - ) - } - - // Template Name Overlay - Surface( - color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp), - modifier = - Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .padding(start = 16.dp, bottom = 100.dp), - ) { - Text( - text = template.name, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = Color.White, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - } -} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/TemplatePreviewItem.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/TemplatePreviewItem.kt new file mode 100644 index 00000000..8024d1fc --- /dev/null +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/compose/templates/TemplatePreviewItem.kt @@ -0,0 +1,94 @@ +package com.tejpratapsingh.lyricsmaker.presentation.compose.templates + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.tejpratapsingh.lyricsmaker.presentation.motion.createLyricsVideoPreviewProducer +import com.tejpratapsingh.motionlib.core.motion.MotionVideoProducer +import com.tejpratapsingh.motionlib.templates.model.MotionTemplate +import com.tejpratapsingh.motionlib.ui.custom.video.MotionVideoPlayerCompose +import com.tejpratapsingh.motionstore.tables.MotionProject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun TemplatePreviewItem( + project: MotionProject, + template: MotionTemplate, + isActive: Boolean, +) { + val context = LocalContext.current + + val motionVideoProducer by + produceState(initialValue = null, project.id, template.name) { + value = + withContext(Dispatchers.Default) { + createLyricsVideoPreviewProducer(context, project, template) + } + } + + Surface( + modifier = + Modifier + .fillMaxSize() + .padding(vertical = 112.dp) + .clip(RoundedCornerShape(24.dp)), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 8.dp, + shadowElevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + motionVideoProducer?.let { + MotionVideoPlayerCompose( + motionVideoProducer = it, + modifier = Modifier.fillMaxSize(), + isPlaying = isActive, + showControls = false, + ) + } ?: run { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + ) + } + + // Template Name Overlay + Surface( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 16.dp), + ) { + Text( + text = template.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } +} diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/viewmodel/ProjectsViewModel.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/viewmodel/ProjectsViewModel.kt index 3c6c9b9b..cb55e5b2 100644 --- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/viewmodel/ProjectsViewModel.kt +++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/viewmodel/ProjectsViewModel.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.tejpratapsingh.lyricsmaker.presentation.compose.ThumbnailCache +import com.tejpratapsingh.lyricsmaker.presentation.compose.projects.ThumbnailCache import com.tejpratapsingh.motionstore.dao.MotionProjectDao import com.tejpratapsingh.motionstore.extensions.deleteProjectFolder import com.tejpratapsingh.motionstore.infra.PreferenceManager diff --git a/modules/lyrics-maker/src/test/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ScreenTest.kt b/modules/lyrics-maker/src/test/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ScreenTest.kt index a7bd96c9..dccb3535 100644 --- a/modules/lyrics-maker/src/test/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ScreenTest.kt +++ b/modules/lyrics-maker/src/test/java/com/tejpratapsingh/lyricsmaker/presentation/compose/ScreenTest.kt @@ -1,5 +1,6 @@ package com.tejpratapsingh.lyricsmaker.presentation.compose +import com.tejpratapsingh.lyricsmaker.presentation.compose.navigation.Screen import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test