diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 58334ccd..4f00e00a 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -22,6 +22,7 @@
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6ef25ce3..47b7e964 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -103,6 +103,7 @@ android-application = { id = "com.android.application", version.ref = "agpVersio
android-library = { id = "com.android.library", version.ref = "agpVersion" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinVersion" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinVersion" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesVersion" }
diff --git a/modules/core/src/main/java/com/tejpratapsingh/motionlib/core/extensions/KtorExtension.kt b/modules/core/src/main/java/com/tejpratapsingh/motionlib/core/extensions/KtorExtension.kt
index 83a64747..25b19508 100644
--- a/modules/core/src/main/java/com/tejpratapsingh/motionlib/core/extensions/KtorExtension.kt
+++ b/modules/core/src/main/java/com/tejpratapsingh/motionlib/core/extensions/KtorExtension.kt
@@ -72,11 +72,13 @@ suspend fun HttpClient.downloadFile(
}
}
-suspend fun HttpClient.fetchBitmap(url: String): Bitmap? =
- try {
+suspend fun HttpClient.fetchBitmap(url: String): Bitmap? {
+ return try {
+ if (url.isBlank()) return null
val bytes: ByteArray = get(url).body()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
e.printStackTrace()
null
}
+}
diff --git a/modules/lyrics-maker/build.gradle b/modules/lyrics-maker/build.gradle
index ee2413d1..361621e5 100644
--- a/modules/lyrics-maker/build.gradle
+++ b/modules/lyrics-maker/build.gradle
@@ -41,6 +41,7 @@ android {
dependencies {
implementation project(path: ':modules:core')
+ implementation project(path: ':modules:motion-video-editor')
implementation libs.androidx.appcompat
implementation libs.google.android.material
implementation libs.androidx.activity
diff --git a/modules/lyrics-maker/src/main/AndroidManifest.xml b/modules/lyrics-maker/src/main/AndroidManifest.xml
index be90bd2a..391a4cc9 100644
--- a/modules/lyrics-maker/src/main/AndroidManifest.xml
+++ b/modules/lyrics-maker/src/main/AndroidManifest.xml
@@ -24,6 +24,7 @@
diff --git a/modules/lyrics-maker/src/main/assets/fonts/Malam_Poek.ttf b/modules/lyrics-maker/src/main/assets/fonts/Malam_Poek.ttf
new file mode 100644
index 00000000..3f6f4fef
Binary files /dev/null and b/modules/lyrics-maker/src/main/assets/fonts/Malam_Poek.ttf differ
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/data/api/albumart/client/AlbumArtRemoteDataSourceImpl.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/data/api/albumart/client/AlbumArtRemoteDataSourceImpl.kt
index 509f9e87..a6fb0494 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/data/api/albumart/client/AlbumArtRemoteDataSourceImpl.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/data/api/albumart/client/AlbumArtRemoteDataSourceImpl.kt
@@ -68,13 +68,21 @@ class AlbumArtRemoteDataSourceImpl(
override suspend fun fetchBitmap(url: String): Bitmap? =
withContext(Dispatchers.IO) {
- val request = Request.Builder().url(url).build()
+ try {
+ if (url.isBlank()) {
+ return@withContext null
+ }
+ val request = Request.Builder().url(url).build()
- client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) return@withContext null
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@withContext null
- val bytes = response.body()?.bytes() ?: return@withContext null
- BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ val bytes = response.body()?.bytes() ?: return@withContext null
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "fetchBitmap failed for url: $url")
+ null
}
}
}
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 8dbe0794..5924a80e 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
@@ -12,10 +12,12 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.content.FileProvider
@@ -57,7 +59,6 @@ class SearchActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- LyricsMotionWorker.cancelAllWork(applicationContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ActivityCompat.checkSelfPermission(
@@ -73,23 +74,25 @@ class SearchActivity : ComponentActivity() {
}
}
- val metadata = ShareReceiverActivity.readMetadataFromIntent(intent)
- metadata?.let {
- lyricsViewModel.socialMeta.value = it
- lyricsViewModel.query.value = it.title ?: it.description ?: ""
- lyricsViewModel.searchLyrics(it.title ?: it.description ?: "")
- }
+ handleIntent(intent)
setContent {
val navController = rememberNavController()
- LaunchedEffect(metadata) {
- if (metadata != null) {
- navController.navigate(Screen.Search.route)
+ val socialMeta by lyricsViewModel.socialMeta.collectAsState()
+
+ LaunchedEffect(socialMeta) {
+ if (socialMeta.title != null || socialMeta.description != null) {
+ navController.navigate(Screen.Search.route) {
+ launchSingleTop = true
+ }
}
}
AnimatorTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ ) { _ ->
AppNavHost(
navController = navController,
projectsViewModel = projectsViewModel,
@@ -97,7 +100,7 @@ class SearchActivity : ComponentActivity() {
navController.navigate(Screen.ProjectDetails.createRoute(motionProject.id))
},
lyricsViewModel = lyricsViewModel,
- modifier = Modifier.padding(innerPadding),
+ modifier = Modifier, // Padding handled by internal screens
)
}
}
@@ -128,6 +131,24 @@ class SearchActivity : ComponentActivity() {
}
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleIntent(intent)
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ LyricsMotionWorker.cancelAllWork(applicationContext)
+ intent?.let {
+ val metadata = ShareReceiverActivity.readMetadataFromIntent(it)
+ metadata?.let { socialMeta ->
+ lyricsViewModel.socialMeta.value = socialMeta
+ lyricsViewModel.query.value = socialMeta.title ?: socialMeta.description ?: ""
+ lyricsViewModel.searchLyrics(socialMeta.title ?: socialMeta.description ?: "")
+ }
+ }
+ }
+
/**
* Share Project
*/
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/AppNavHost.kt
index ea5a608d..b8227486 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/AppNavHost.kt
@@ -10,9 +10,14 @@ 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.motion.extractLyricsTemplateData
import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsViewModel
import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.ProjectsViewModel
+import com.tejpratapsingh.motioneditor.ui.MotionEditorScreen
+import com.tejpratapsingh.motionlib.core.MotionConfig
+import com.tejpratapsingh.motionlib.core.VideoAspectRatio
import com.tejpratapsingh.motionlib.core.extensions.md5
+import com.tejpratapsingh.motionlib.templates.sdui.MotionTemplateSDUIProvider
import com.tejpratapsingh.motionstore.tables.MotionProject
sealed class Screen(
@@ -31,6 +36,10 @@ sealed class Screen(
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
@@ -128,13 +137,63 @@ fun AppNavHost(
it.metadata.addProperty("template", template.name)
// Clear old SDUI if any
it.metadata.remove("sdui")
-
- navController.context.asLyricsApp().motionStoreDao.upsert(it)
+
+ val templateData = extractLyricsTemplateData(it)
+
+ val config =
+ MotionConfig(
+ aspectRatio = VideoAspectRatio.Ratio9x16_480,
+ fps = 24,
+ )
+
+ // Generate and save SDUI
+ val sdui =
+ MotionTemplateSDUIProvider.provideSDUI(
+ context = navController.context,
+ template = template,
+ data = templateData,
+ config = config,
+ )
+
+ val updatedProject = it.copy(sdui = sdui)
+
+ navController.context
+ .asLyricsApp()
+ .motionStoreDao
+ .upsert(updatedProject)
+
projectsViewModel.loadProjects()
-
- navController.navigate(Screen.ProjectDetails.createRoute(it.id)) {
+
+ navController.navigate(Screen.VideoEditor.createRoute(it.id)) {
// Pop the template selector so back from details goes to lyrics
popUpTo(Screen.TemplateSelector.route) { inclusive = true }
+ launchSingleTop = true
+ }
+ },
+ modifier = modifier,
+ )
+ }
+ }
+
+ composable(route = Screen.VideoEditor.route) { backStackEntry ->
+ val projectId = backStackEntry.arguments?.getString("projectId")
+ val projects = projectsViewModel.projects.collectAsStateWithLifecycle()
+ val project = projects.value.find { it.id == projectId }
+
+ project?.let {
+ MotionEditorScreen(
+ project = it,
+ onBackClick = { navController.popBackStack() },
+ onSaveClick = { updatedProject ->
+ navController.context
+ .asLyricsApp()
+ .motionStoreDao
+ .upsert(updatedProject)
+
+ projectsViewModel.loadProjects()
+ navController.navigate(Screen.ProjectDetails.createRoute(updatedProject.id)) {
+ popUpTo(Screen.Projects.route) { inclusive = false }
+ launchSingleTop = true
}
},
modifier = modifier,
@@ -151,6 +210,11 @@ fun AppNavHost(
ProjectDetailsScreen(
project = it,
onBackClick = { navController.popBackStack() },
+ onEditClick = { p ->
+ navController.navigate(Screen.VideoEditor.createRoute(p.id)) {
+ launchSingleTop = true
+ }
+ },
onShareClick = { p -> projectsViewModel.shareProject(p) },
modifier = modifier,
)
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/GradientTextCompose.kt
index 3bca1053..64961e13 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/GradientTextCompose.kt
@@ -9,10 +9,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.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
@@ -21,6 +27,14 @@ fun GradientText(
text: String,
modifier: Modifier = Modifier,
) {
+ val context = LocalContext.current
+ val malamPoek =
+ remember(context) {
+ FontFamily(
+ Font(path = "fonts/Malam_Poek.ttf", assetManager = context.assets),
+ )
+ }
+
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val offset by infiniteTransition.animateFloat(
initialValue = 0f,
@@ -35,22 +49,30 @@ fun GradientText(
val gradientColors =
listOf(
- ThemeBlue,
ThemePink,
+ ThemeBlue,
)
Text(
text = text,
style =
MaterialTheme.typography.displayMedium.copy(
+ fontFamily = malamPoek,
brush =
Brush.linearGradient(
colors = gradientColors,
start = Offset(offset, offset),
end = Offset(offset + 5f, offset + 5f),
),
+ fontSize = 48.sp,
),
fontWeight = FontWeight.ExtraBold,
modifier = modifier,
)
}
+
+@Preview(showBackground = true)
+@Composable
+fun GradientTextPreview() {
+ GradientText(text = "Lyrics Maker")
+}
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/LyricsTemplateSelector.kt
index a0dfd8ad..4eb792d6 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/LyricsTemplateSelector.kt
@@ -2,21 +2,21 @@ package com.tejpratapsingh.lyricsmaker.presentation.compose
import androidx.compose.foundation.background
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
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.rounded.Check
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@@ -57,75 +56,85 @@ fun LyricsTemplateSelector(
modifier =
modifier
.fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
+ .background(Color.Black),
) {
- Column(modifier = Modifier.fillMaxSize()) {
- // Header
- Surface(tonalElevation = 2.dp) {
- Row(
+ // Full Screen Vertical Pager
+ VerticalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize(),
+ ) { page ->
+ TemplatePreviewItem(
+ project = project,
+ template = templates[page],
+ isActive = pagerState.currentPage == page,
+ )
+ }
+
+ // Overlay Header
+ Surface(
+ color = Color.Transparent,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .statusBarsPadding(),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = onBack,
modifier =
- Modifier
- .fillMaxWidth()
- .padding(8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ Modifier.background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
+ shape = RoundedCornerShape(12.dp),
+ ),
) {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
- }
- Text(
- text = "Choose Template",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 8.dp),
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = "Back",
+ tint = Color.White,
)
}
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- // Template Pager
- HorizontalPager(
- state = pagerState,
- modifier = Modifier.weight(1f),
- contentPadding = PaddingValues(horizontal = 48.dp),
- pageSpacing = 24.dp,
- ) { page ->
- TemplatePreviewItem(
- project = project,
- template = templates[page],
+ Text(
+ text = "Choose Template",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ modifier = Modifier.padding(start = 12.dp),
)
}
+ }
- // Bottom action
- Surface(
- tonalElevation = 2.dp,
- modifier = Modifier.fillMaxWidth(),
+ // Overlay Bottom Action
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .padding(24.dp),
+ ) {
+ Button(
+ onClick = {
+ onTemplateSelected(templates[pagerState.currentPage])
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
) {
- Box(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(24.dp),
- ) {
- Button(
- onClick = {
- onTemplateSelected(templates[pagerState.currentPage])
- },
- modifier =
- Modifier
- .fillMaxWidth()
- .height(56.dp),
- shape = RoundedCornerShape(16.dp),
- ) {
- Icon(Icons.Rounded.Check, contentDescription = null)
- Spacer(modifier = Modifier.width(12.dp))
- Text(
- "Next",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- )
- }
- }
+ Text(
+ "Select Template",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null)
}
}
}
@@ -135,6 +144,7 @@ fun LyricsTemplateSelector(
private fun TemplatePreviewItem(
project: MotionProject,
template: MotionTemplate,
+ isActive: Boolean,
) {
val context = LocalContext.current
@@ -146,40 +156,40 @@ private fun TemplatePreviewItem(
}
}
- Column(
+ Box(
modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
+ contentAlignment = Alignment.Center,
) {
- Box(
+ 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
- .weight(1f)
- .fillMaxWidth()
- .clip(RoundedCornerShape(24.dp))
- .background(Color.Black),
- contentAlignment = Alignment.Center,
+ .align(Alignment.BottomStart)
+ .navigationBarsPadding()
+ .padding(start = 16.dp, bottom = 100.dp),
) {
- motionVideoProducer?.let {
- MotionVideoPlayerCompose(
- motionVideoProducer = it,
- modifier = Modifier.fillMaxSize(),
- )
- } ?: run {
- CircularProgressIndicator(
- color = MaterialTheme.colorScheme.primary,
- )
- }
+ Text(
+ text = template.name,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
}
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = template.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
- )
-
- Spacer(modifier = Modifier.height(8.dp))
}
}
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/ProjectDetailsCompose.kt
index a827b29f..2ae569d7 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/ProjectDetailsCompose.kt
@@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
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.shape.CircleShape
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.rounded.Edit
import androidx.compose.material.icons.rounded.PlayCircle
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.Button
@@ -49,6 +51,7 @@ import kotlinx.coroutines.withContext
fun ProjectDetailsScreen(
project: MotionProject,
onBackClick: () -> Unit,
+ onEditClick: (MotionProject) -> Unit,
onShareClick: (MotionProject) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -174,6 +177,7 @@ fun ProjectDetailsScreen(
onClick = onBackClick,
modifier =
Modifier
+ .statusBarsPadding()
.padding(16.dp)
.align(Alignment.TopStart)
.background(
@@ -187,5 +191,25 @@ fun ProjectDetailsScreen(
tint = MaterialTheme.colorScheme.onSurface,
)
}
+
+ // Overlay Edit Button - Positioned at top-right with status bar padding
+ IconButton(
+ onClick = { onEditClick(project) },
+ modifier =
+ Modifier
+ .statusBarsPadding()
+ .padding(16.dp)
+ .align(Alignment.TopEnd)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
+ shape = CircleShape,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Edit,
+ contentDescription = "Edit",
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
}
}
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
index f7d7bc7c..db2984bb 100644
--- 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
@@ -62,10 +62,12 @@ 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
@@ -84,6 +86,8 @@ fun ProjectsRoute(
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
ProjectsScreen(
projects = projects,
isRefreshing = isRefreshing,
@@ -92,7 +96,9 @@ fun ProjectsRoute(
onRefresh = viewModel::refresh,
onCreateNew = onCreateNew,
onProjectClick = onProjectClick,
- onDeleteProject = viewModel::deleteProject,
+ onDeleteProject = { project ->
+ viewModel.deleteProject(context, project)
+ },
onShareProject = viewModel::shareProject,
onSync = viewModel::syncProject,
modifier = modifier,
@@ -122,7 +128,10 @@ fun ProjectsScreen(
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
- modifier = Modifier.padding(padding).fillMaxSize(),
+ modifier =
+ Modifier
+ .padding(padding)
+ .fillMaxSize(),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
@@ -139,7 +148,7 @@ fun ProjectsScreen(
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- GradientText(text = "Lyrics Maker")
+ GradientText(text = stringResource(R.string.app_name))
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.fillMaxWidth(),
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/SearchLyricsCompose.kt
index ff80df54..d5cf38f6 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/SearchLyricsCompose.kt
@@ -1,37 +1,68 @@
package com.tejpratapsingh.lyricsmaker.presentation.compose
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.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.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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.History
+import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
import com.tejpratapsingh.lyricsmaker.data.api.lrclib.model.LyricsResponse
import com.tejpratapsingh.lyricsmaker.data.store.RecentSearchHelper
import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsUiState
import com.tejpratapsingh.lyricsmaker.presentation.viewmodel.LyricsViewModel
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
modifier: Modifier = Modifier,
@@ -45,138 +76,211 @@ fun SearchScreen(
val keyboardController = LocalSoftwareKeyboardController.current
- Column(
+ val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
+ var headerHeightPx by remember { mutableFloatStateOf(0f) }
+
+ LaunchedEffect(headerHeightPx) {
+ scrollBehavior.state.heightOffsetLimit = -headerHeightPx
+ }
+
+ Box(
modifier =
modifier
.fillMaxSize()
- .padding(16.dp),
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
) {
- Text(
- text = "Search Lyrics",
- style = MaterialTheme.typography.headlineLarge,
+ // Main Content
+ Box(
modifier =
Modifier
- .align(CenterHorizontally)
- .padding(16.dp),
- )
- OutlinedTextField(
- value = query.value,
- onValueChange = { viewModel.query.tryEmit(it) },
- label = { Text("Search") },
- singleLine = true,
- trailingIcon = {
- if (uiState.value is LyricsUiState.Loading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- )
- }
- },
- keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search),
- keyboardActions =
- KeyboardActions(
- onSearch = {
- val searchQuery = query.value.trim()
- if (searchQuery.isNotBlank()) {
- keyboardController?.hide()
- RecentSearchHelper.saveSearch(context, searchQuery)
- recentSearches.value = RecentSearchHelper.getSearches(context)
- viewModel.searchLyrics(query = searchQuery)
- }
- },
- ),
- modifier = Modifier.fillMaxWidth(),
- )
-
- when (val state = uiState.value) {
- is LyricsUiState.Success -> {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- ) {
- items(state.lyrics.size) { item ->
- Card(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- .clickable { onLyricsSelected(state.lyrics[item]) },
- colors =
- CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- ),
- ) {
- Text(
- text = "${state.lyrics[item].trackName} - ${state.lyrics[item].artistName}",
- style = MaterialTheme.typography.labelLarge,
- modifier =
- Modifier.padding(
- start = 16.dp,
- top = 16.dp,
- end = 16.dp,
- bottom = 2.dp,
- ),
- )
- Text(
- text = "Duration: ${state.lyrics[item].getReadableDuration()}",
- maxLines = 2,
- style = MaterialTheme.typography.bodyMedium,
- modifier =
- Modifier.padding(
- start = 16.dp,
- top = 2.dp,
- end = 16.dp,
- bottom = 2.dp,
- ),
- )
- Text(
- text = state.lyrics[item].getLyrics(),
- maxLines = 2,
- style = MaterialTheme.typography.bodyMedium,
+ .fillMaxSize()
+ .offset {
+ IntOffset(
+ x = 0,
+ y = (headerHeightPx + scrollBehavior.state.heightOffset).toInt(),
+ )
+ }
+ .padding(horizontal = 16.dp),
+ ) {
+ when (val state = uiState.value) {
+ is LyricsUiState.Success -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item {
+ Card(
modifier =
- Modifier.padding(
- start = 16.dp,
- top = 2.dp,
- end = 16.dp,
- bottom = 16.dp,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
- )
- Spacer(Modifier.height(4.dp))
+ ) {
+ state.lyrics.forEachIndexed { index, lyrics ->
+ ListItem(
+ headlineContent = {
+ Text(
+ text = "${lyrics.trackName} - ${lyrics.artistName}",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ supportingContent = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Rounded.Timer,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = lyrics.getReadableDuration(),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ Text(
+ text = lyrics.getLyrics(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.MusicNote,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier = Modifier.clickable { onLyricsSelected(lyrics) },
+ )
+ if (index < state.lyrics.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
+ )
+ }
+ }
+ }
}
}
}
- }
- else -> {
- Spacer(modifier = Modifier.height(16.dp))
- Text("Recent Searches:", style = MaterialTheme.typography.titleMedium)
- LazyColumn {
- items(recentSearches.value.size) { idx ->
- Card(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- .clickable {
- viewModel.query.tryEmit(recentSearches.value[idx])
- keyboardController?.hide()
- viewModel.searchLyrics(recentSearches.value[idx])
- },
- colors =
- CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- ),
- ) {
- Text(
- text = recentSearches.value[idx],
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(16.dp),
- )
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ if (recentSearches.value.isNotEmpty()) {
+ Text("Recent Searches:", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ ) {
+ recentSearches.value.forEachIndexed { index, search ->
+ ListItem(
+ headlineContent = { Text(search) },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Rounded.History,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier =
+ Modifier.clickable {
+ viewModel.query.tryEmit(search)
+ keyboardController?.hide()
+ viewModel.searchLyrics(search)
+ },
+ )
+ if (index < recentSearches.value.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
+ )
+ }
+ }
+ }
+ }
}
}
}
}
}
+
+ // Floating Header
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned {
+ headerHeightPx = it.size.height.toFloat()
+ }
+ .offset {
+ IntOffset(
+ x = 0,
+ y = scrollBehavior.state.heightOffset.toInt(),
+ )
+ }
+ .statusBarsPadding()
+ .zIndex(1f),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Text(
+ text = "Search Lyrics",
+ style = MaterialTheme.typography.headlineLarge,
+ modifier =
+ Modifier
+ .align(CenterHorizontally)
+ .padding(16.dp),
+ )
+ OutlinedTextField(
+ value = query.value,
+ onValueChange = { viewModel.query.tryEmit(it) },
+ label = { Text("Search") },
+ singleLine = true,
+ trailingIcon = {
+ if (uiState.value is LyricsUiState.Loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search),
+ keyboardActions =
+ KeyboardActions(
+ onSearch = {
+ val searchQuery = query.value.trim()
+ if (searchQuery.isNotBlank()) {
+ keyboardController?.hide()
+ RecentSearchHelper.saveSearch(context, searchQuery)
+ recentSearches.value = RecentSearchHelper.getSearches(context)
+ viewModel.searchLyrics(query = searchQuery)
+ }
+ },
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
}
}
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/SyncedLyricsSelector.kt
index 386a5485..ed6ed5e7 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/SyncedLyricsSelector.kt
@@ -12,9 +12,11 @@ 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.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
@@ -377,7 +379,10 @@ fun SyncedLyricsSelector(
Column(modifier = Modifier.fillMaxSize()) {
// ── Summary bar ───────────────────────────────────────────────────────
if (lyrics.isNotEmpty()) {
- Surface(tonalElevation = 2.dp) {
+ Surface(
+ tonalElevation = 2.dp,
+ modifier = Modifier.statusBarsPadding()
+ ) {
Row(
modifier =
Modifier
@@ -532,6 +537,7 @@ fun SyncedLyricsSelector(
modifier =
Modifier
.align(Alignment.BottomEnd)
+ .navigationBarsPadding()
.padding(16.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/motion/MultiLyricsVideoProducer.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/motion/MultiLyricsVideoProducer.kt
index 0849484a..2ea2ddf7 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/motion/MultiLyricsVideoProducer.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/motion/MultiLyricsVideoProducer.kt
@@ -18,6 +18,38 @@ import com.tejpratapsingh.motionlib.templates.model.TemplateData
import com.tejpratapsingh.motionstore.tables.MotionProject
import timber.log.Timber
+fun extractLyricsTemplateData(motionProject: MotionProject): TemplateData {
+ val lyrics =
+ motionProject.metadata.get("lyrics")?.takeIf { it.isJsonArray }?.asJsonArray?.map {
+ SyncedLyricFrame(
+ frame =
+ it.asJsonObject
+ .get("frame")
+ ?.takeIf { f -> f.isJsonPrimitive }
+ ?.asInt ?: 0,
+ text =
+ it.asJsonObject
+ .get("text")
+ ?.takeIf { t -> t.isJsonPrimitive }
+ ?.asString ?: "",
+ )
+ } ?: emptyList()
+
+ val image =
+ motionProject.metadata
+ .get("image")
+ ?.takeIf { it.isJsonPrimitive }
+ ?.asString
+
+ return TemplateData(
+ mapOf(
+ "songName" to motionProject.name,
+ "image" to (image ?: ""),
+ "lyrics" to lyrics,
+ ),
+ )
+}
+
fun getMultiLyricsVideoProducer(
applicationContext: Context,
motionProject: MotionProject,
@@ -84,32 +116,6 @@ private fun createLyricsVideoProducerInternal(
): MotionVideoProducer {
Timber.i("createLyricsVideoProducerInternal: $motionProject, isPreview: $isPreview")
- val lyrics =
- motionProject.metadata.get("lyrics")?.takeIf { it.isJsonArray }?.asJsonArray?.map {
- SyncedLyricFrame(
- frame =
- it.asJsonObject
- .get("frame")
- ?.takeIf { f -> f.isJsonPrimitive }
- ?.asInt ?: 0,
- text =
- it.asJsonObject
- .get("text")
- ?.takeIf { t -> t.isJsonPrimitive }
- ?.asString ?: "",
- ).also { lyricFrame ->
- Timber.d("lyricFrame: $lyricFrame")
- }
- } ?: emptyList()
-
- Timber.d("createLyricsVideoProducerInternal: ${lyrics.size}")
-
- val image =
- motionProject.metadata
- .get("image")
- ?.takeIf { it.isJsonPrimitive }
- ?.asString
-
val motionConfig =
MotionConfig(
aspectRatio = VideoAspectRatio.Ratio9x16_480,
@@ -125,14 +131,7 @@ private fun createLyricsVideoProducerInternal(
videoProducerAdapter = FfmpegVideoProducerAdapter(),
)
- val templateData =
- TemplateData(
- mapOf(
- "songName" to motionProject.name,
- "image" to (image ?: ""),
- "lyrics" to lyrics,
- ),
- )
+ val templateData = extractLyricsTemplateData(motionProject)
val contentScope =
ContentScope(
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/AccentLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/AccentLyricsTemplate.kt
index 5c14fd01..a41e5809 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/AccentLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/AccentLyricsTemplate.kt
@@ -11,6 +11,7 @@ import com.tejpratapsingh.motionlib.core.motion.transitions.CrossFadeTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
import com.tejpratapsingh.motionlib.templates.extensions.accentMiddlePopUpTextView
import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
val AccentLyricsTemplate: MotionTemplate =
@@ -36,16 +37,23 @@ val AccentLyricsTemplate: MotionTemplate =
)
}
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ )
+
lyrics.zipWithNext().forEach { (current, next) ->
accentMiddlePopUpTextView(
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
- writingSpeed = 1.0f,
+ writingSpeed = 1.5f,
unwrittenTextAlpha = 0.4f,
maxTranslationY = 40f,
accentColor = "#FFC107".toColorInt(), // Amber color
- textSizeVariant = MotionTextVariant.H2,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#F5F5F5", // Off-white
textView =
AppCompatTextView(context).apply {
@@ -77,16 +85,23 @@ val AccentLyricsTemplate: MotionTemplate =
)
}
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ )
+
previewLyrics.zipWithNext().forEach { (current, next) ->
accentMiddlePopUpTextView(
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
- writingSpeed = 1.0f,
+ writingSpeed = 1.5f,
unwrittenTextAlpha = 0.4f,
maxTranslationY = 40f,
accentColor = "#FFC107".toColorInt(),
- textSizeVariant = MotionTextVariant.H2,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#F5F5F5",
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GlitchLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GlitchLyricsTemplate.kt
index e100b3ee..b1a764aa 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GlitchLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GlitchLyricsTemplate.kt
@@ -39,6 +39,7 @@ val GlitchLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
effects = listOf(GlitchEffect(current.frame, next.frame, intensity = 30f)),
) {
textView.apply {
@@ -74,6 +75,7 @@ val GlitchLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
effects = listOf(GlitchEffect(current.frame, next.frame, intensity = 10f)),
) {
textView.apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GradientLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GradientLyricsTemplate.kt
index 85102950..024f6391 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GradientLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/GradientLyricsTemplate.kt
@@ -48,6 +48,7 @@ val GradientLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
textView =
AppCompatTextView(context).apply {
setPadding(32, 32, 32, 32)
@@ -90,6 +91,7 @@ val GradientLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
textView =
AppCompatTextView(context).apply {
setPadding(32, 32, 32, 32)
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/PopupLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/PopupLyricsTemplate.kt
index 52a68814..3cba644e 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/PopupLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/PopupLyricsTemplate.kt
@@ -2,12 +2,15 @@ package com.tejpratapsingh.lyricsmaker.presentation.templates
import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.net.toUri
import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame
import com.tejpratapsingh.motionlib.core.MotionTextVariant
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideDirection
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
+import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
import com.tejpratapsingh.motionlib.templates.extensions.popUpTextView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
val PopupLyricsTemplate: MotionTemplate =
@@ -24,21 +27,30 @@ val PopupLyricsTemplate: MotionTemplate =
val lyrics = data.get>("lyrics") ?: emptyList()
if (lyrics.isNotEmpty()) {
- multiLyricsContainer(
- songName = songName,
+
+ image?.let {
+ motionImageView(
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
startFrame = lyrics.first().frame,
endFrame = lyrics.last().frame,
- image = image,
)
- lyrics.zipWithNext().slice(0..1).forEach { (current, next) ->
+ lyrics.zipWithNext().forEach { (current, next) ->
popUpTextView(
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
@@ -60,11 +72,20 @@ val PopupLyricsTemplate: MotionTemplate =
if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
val endFrame = previewLyrics.last().frame
- multiLyricsContainer(
- songName = songName,
- startFrame = lyrics.first().frame,
- endFrame = endFrame,
- image = image,
+
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
)
previewLyrics.zipWithNext().forEach { (current, next) ->
@@ -74,7 +95,7 @@ val PopupLyricsTemplate: MotionTemplate =
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/RainbowLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/RainbowLyricsTemplate.kt
index 4e6a11bb..020fbf53 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/RainbowLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/RainbowLyricsTemplate.kt
@@ -11,6 +11,7 @@ import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
import com.tejpratapsingh.motionlib.templates.extensions.accentMiddlePopUpTextView
import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
import com.tejpratapsingh.motionlib.templates.extensions.rainbowPopUpTextView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
val RainbowLyricsTemplate: MotionTemplate =
@@ -25,17 +26,21 @@ val RainbowLyricsTemplate: MotionTemplate =
val lyrics = data.get>("lyrics") ?: emptyList()
if (lyrics.isNotEmpty()) {
- val startFrame = lyrics.first().frame
- val endFrame = lyrics.last().frame
-
image?.let {
motionImageView(
- startFrame = startFrame,
- endFrame = endFrame,
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
imageUri = image.toUri(),
)
}
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ )
+
lyrics.zipWithNext().forEachIndexed { index, (current, next) ->
if (index % 2 == 0) {
rainbowPopUpTextView(
@@ -43,6 +48,67 @@ val RainbowLyricsTemplate: MotionTemplate =
startFrame = current.frame,
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
+ writingSpeed = 1.5f,
+ textView =
+ AppCompatTextView(context).apply {
+ setPadding(32, 32, 32, 32)
+ textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
+ gravity = Gravity.CENTER
+ setShadowLayer(10f, 0f, 0f, Color.BLACK)
+ },
+ )
+ } else {
+ accentMiddlePopUpTextView(
+ text = current.text,
+ startFrame = current.frame,
+ endFrame = next.frame,
+ textSizeVariant = MotionTextVariant.H1,
+ accentColor = Color.CYAN,
+ writingSpeed = 1.5f,
+ textView =
+ AppCompatTextView(context).apply {
+ setPadding(32, 32, 32, 32)
+ textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
+ gravity = Gravity.CENTER
+ setShadowLayer(10f, 0f, 0f, Color.BLACK)
+ },
+ )
+ }
+ transition(CrossFadeTransition(), duration = 15)
+ }
+ }
+ }
+
+ preview {
+ val songName = data.getString("songName") ?: ""
+ val image = data.getString("image")
+ val lyrics = data.get>("lyrics") ?: emptyList()
+
+ if (lyrics.isNotEmpty()) {
+ val previewLyrics = lyrics.take(3)
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ )
+
+ previewLyrics.zipWithNext().forEachIndexed { index, (current, next) ->
+ if (index % 2 == 0) {
+ rainbowPopUpTextView(
+ text = current.text,
+ startFrame = current.frame,
+ endFrame = next.frame,
+ textSizeVariant = MotionTextVariant.H1,
+ writingSpeed = 1.5f,
textView =
AppCompatTextView(context).apply {
setPadding(32, 32, 32, 32)
@@ -57,6 +123,7 @@ val RainbowLyricsTemplate: MotionTemplate =
startFrame = current.frame,
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
+ writingSpeed = 1.5f,
accentColor = Color.CYAN,
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/TypewriterLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/TypewriterLyricsTemplate.kt
index 00b49fdd..1db305b3 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/TypewriterLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/TypewriterLyricsTemplate.kt
@@ -2,11 +2,14 @@ package com.tejpratapsingh.lyricsmaker.presentation.templates
import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.net.toUri
import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame
import com.tejpratapsingh.motionlib.core.MotionTextVariant
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideDirection
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
+import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.extensions.typeWriterTextView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
@@ -23,11 +26,19 @@ val TypewriterLyricsTemplate: MotionTemplate =
val lyrics = data.get>("lyrics") ?: emptyList()
if (lyrics.isNotEmpty()) {
- multiLyricsContainer(
- songName = songName,
+ image?.let {
+ motionImageView(
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
startFrame = lyrics.first().frame,
endFrame = lyrics.last().frame,
- image = image,
)
lyrics.zipWithNext().forEach { (current, next) ->
@@ -37,7 +48,7 @@ val TypewriterLyricsTemplate: MotionTemplate =
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
@@ -59,11 +70,20 @@ val TypewriterLyricsTemplate: MotionTemplate =
if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
val endFrame = previewLyrics.last().frame
- multiLyricsContainer(
- songName = songName,
- startFrame = lyrics.first().frame,
- endFrame = endFrame,
- image = image,
+
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
)
previewLyrics.zipWithNext().forEach { (current, next) ->
@@ -73,7 +93,7 @@ val TypewriterLyricsTemplate: MotionTemplate =
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/VibrateLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/VibrateLyricsTemplate.kt
index 57801643..82b6b54a 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/VibrateLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/VibrateLyricsTemplate.kt
@@ -2,11 +2,14 @@ package com.tejpratapsingh.lyricsmaker.presentation.templates
import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.net.toUri
import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame
import com.tejpratapsingh.motionlib.core.MotionTextVariant
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideDirection
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
+import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.extensions.wordWriterTextView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
import com.tejpratapsingh.motionlib.ui.effects.VibrateEffect
@@ -24,11 +27,20 @@ val VibrateLyricsTemplate: MotionTemplate =
val lyrics = data.get>("lyrics") ?: emptyList()
if (lyrics.isNotEmpty()) {
- multiLyricsContainer(
- songName = songName,
+
+ image?.let {
+ motionImageView(
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
startFrame = lyrics.first().frame,
endFrame = lyrics.last().frame,
- image = image,
)
lyrics.zipWithNext().forEach { (current, next) ->
@@ -36,8 +48,9 @@ val VibrateLyricsTemplate: MotionTemplate =
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
- textSizeVariant = MotionTextVariant.H2,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFF00",
+ writingSpeed = 1.5f,
effects = listOf(VibrateEffect(current.frame, next.frame, amplitude = 10f, frequency = 0.5f)),
textView =
AppCompatTextView(context).apply {
@@ -58,12 +71,19 @@ val VibrateLyricsTemplate: MotionTemplate =
if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
- val endFrame = previewLyrics.last().frame
- multiLyricsContainer(
- songName = songName,
- startFrame = lyrics.first().frame,
- endFrame = endFrame,
- image = image,
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
)
previewLyrics.zipWithNext().forEach { (current, next) ->
@@ -71,8 +91,9 @@ val VibrateLyricsTemplate: MotionTemplate =
text = current.text,
startFrame = current.frame,
endFrame = next.frame,
- textSizeVariant = MotionTextVariant.H2,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFF00",
+ writingSpeed = 1.5f,
effects = listOf(VibrateEffect(current.frame, next.frame, amplitude = 10f, frequency = 0.5f)),
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/WordwriterLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/WordwriterLyricsTemplate.kt
index 8eae6e70..fc0c7c1b 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/WordwriterLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/WordwriterLyricsTemplate.kt
@@ -2,11 +2,14 @@ package com.tejpratapsingh.lyricsmaker.presentation.templates
import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.net.toUri
import com.tejpratapsingh.lyricsmaker.data.lrc.SyncedLyricFrame
import com.tejpratapsingh.motionlib.core.MotionTextVariant
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideDirection
import com.tejpratapsingh.motionlib.core.motion.transitions.SlideTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
+import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.extensions.wordWriterTextView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
@@ -23,11 +26,20 @@ val WordwriterLyricsTemplate: MotionTemplate =
val lyrics = data.get>("lyrics") ?: emptyList()
if (lyrics.isNotEmpty()) {
- multiLyricsContainer(
- songName = songName,
+
+ image?.let {
+ motionImageView(
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
startFrame = lyrics.first().frame,
endFrame = lyrics.last().frame,
- image = image,
)
lyrics.zipWithNext().forEach { (current, next) ->
@@ -37,7 +49,7 @@ val WordwriterLyricsTemplate: MotionTemplate =
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
@@ -58,12 +70,19 @@ val WordwriterLyricsTemplate: MotionTemplate =
if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
- val endFrame = previewLyrics.last().frame
- multiLyricsContainer(
- songName = songName,
- startFrame = lyrics.first().frame,
- endFrame = endFrame,
- image = image,
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
+ }
+
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
)
previewLyrics.zipWithNext().forEach { (current, next) ->
@@ -73,7 +92,7 @@ val WordwriterLyricsTemplate: MotionTemplate =
endFrame = next.frame,
writingSpeed = 1.5f,
unwrittenTextAlpha = 0.3f,
- textSizeVariant = MotionTextVariant.H3,
+ textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
textView =
AppCompatTextView(context).apply {
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/ZoomLyricsTemplate.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/ZoomLyricsTemplate.kt
index d2a9a437..e7e38326 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/ZoomLyricsTemplate.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/templates/ZoomLyricsTemplate.kt
@@ -10,6 +10,7 @@ import com.tejpratapsingh.motionlib.core.motion.transitions.CrossFadeTransition
import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
import com.tejpratapsingh.motionlib.templates.extensions.motionImageView
import com.tejpratapsingh.motionlib.templates.extensions.popUpTextView
+import com.tejpratapsingh.motionlib.templates.extensions.translucentMotionView
import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
import com.tejpratapsingh.motionlib.ui.effects.FadeInEffect
import com.tejpratapsingh.motionlib.ui.effects.ZoomInEffect
@@ -35,6 +36,13 @@ val ZoomLyricsTemplate: MotionTemplate =
)
}
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = lyrics.first().frame,
+ endFrame = lyrics.last().frame,
+ )
+
lyrics.zipWithNext().forEach { (current, next) ->
popUpTextView(
text = current.text,
@@ -42,6 +50,7 @@ val ZoomLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
effects =
listOf(
ZoomInEffect(current.frame, next.frame, startScale = 0.8f, endScale = 1.5f),
@@ -66,16 +75,21 @@ val ZoomLyricsTemplate: MotionTemplate =
if (lyrics.isNotEmpty()) {
val previewLyrics = lyrics.take(3)
- val endFrame = previewLyrics.last().frame
- multiLyricsContainer(
- songName = songName,
- startFrame = lyrics.first().frame,
- endFrame = endFrame,
- image = image,
- ).apply {
- fakeChartView.isVisible = false
+ image?.let {
+ motionImageView(
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ imageUri = image.toUri(),
+ )
}
+ translucentMotionView(
+ color = "#000000",
+ alpha = 0.4f,
+ startFrame = previewLyrics.first().frame,
+ endFrame = previewLyrics.last().frame,
+ )
+
previewLyrics.zipWithNext().forEach { (current, next) ->
popUpTextView(
text = current.text,
@@ -83,6 +97,7 @@ val ZoomLyricsTemplate: MotionTemplate =
endFrame = next.frame,
textSizeVariant = MotionTextVariant.H1,
textColor = "#FFFFFF",
+ writingSpeed = 1.5f,
effects =
listOf(
ZoomInEffect(current.frame, next.frame, startScale = 0.8f, endScale = 1.5f),
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/LyricsContainer.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/LyricsContainer.kt
index 53369454..3e79b127 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/LyricsContainer.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/LyricsContainer.kt
@@ -3,6 +3,7 @@ package com.tejpratapsingh.lyricsmaker.presentation.view
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
+import android.util.TypedValue
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.SeekBar
@@ -65,9 +66,9 @@ class LyricsContainer(
fakeChartView = view.findViewById(R.id.fake_chart_view)
tvSongName.text = songName
- tvSongName.textSize = fontSizeH5
- tvLyricsLine1.textSize = fontSizeH3
- tvLyricsLine2.textSize = fontSizeH3
+ tvSongName.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizeH5)
+ tvLyricsLine1.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizeH3)
+ tvLyricsLine2.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizeH3)
progress.progress = startFrame
progress.max = endFrame
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/MultiLyricsContainer.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/MultiLyricsContainer.kt
index 348cba62..8140f333 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/MultiLyricsContainer.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/view/MultiLyricsContainer.kt
@@ -2,6 +2,7 @@ package com.tejpratapsingh.lyricsmaker.presentation.view
import android.content.Context
import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.graphics.Color
import android.widget.ImageView
import com.tejpratapsingh.lyricsmaker.R
@@ -49,11 +50,9 @@ class MultiLyricsContainer(
ivAlbumArt.apply {
runBlocking {
+ var bitmap: Bitmap? = null
if (image != null) {
- val bitmap = repository.getAlbumArtBitmap(image)
- bitmap?.let {
- setImageBitmap(it)
- }
+ bitmap = repository.getAlbumArtBitmap(image)
} else {
Timber.i("Fetching from musicbrainz")
val songDetails = songName.split(" - ")
@@ -65,13 +64,22 @@ class MultiLyricsContainer(
)
url?.let { url ->
- val bitmap = repository.getAlbumArtBitmap(url)
- bitmap?.let {
- setImageBitmap(it)
- }
+ bitmap = repository.getAlbumArtBitmap(url)
}
}
}
+
+ if (bitmap == null) {
+ bitmap =
+ BitmapFactory.decodeResource(
+ context.resources,
+ com.tejpratapsingh.motionlib.R.drawable.default_bg,
+ )
+ }
+
+ bitmap?.let {
+ setImageBitmap(it)
+ }
}
}
}
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 733ea20c..3c6c9b9b 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
@@ -1,9 +1,12 @@
package com.tejpratapsingh.lyricsmaker.presentation.viewmodel
+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.motionstore.dao.MotionProjectDao
+import com.tejpratapsingh.motionstore.extensions.deleteProjectFolder
import com.tejpratapsingh.motionstore.infra.PreferenceManager
import com.tejpratapsingh.motionstore.tables.MotionProject
import kotlinx.coroutines.Dispatchers
@@ -60,8 +63,13 @@ class ProjectsViewModel(
}
}
- fun deleteProject(project: MotionProject) {
- viewModelScope.launch {
+ fun deleteProject(
+ context: Context,
+ project: MotionProject,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ context.deleteProjectFolder(project)
+ ThumbnailCache.remove(project.id)
motionProject.deleteById(project.id)
loadProjects()
}
diff --git a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt
index 5b7c9b74..629a9874 100644
--- a/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt
+++ b/modules/lyrics-maker/src/main/java/com/tejpratapsingh/lyricsmaker/presentation/worker/LyricsMotionWorker.kt
@@ -8,10 +8,8 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
-import android.graphics.Color
import android.net.Uri
import android.os.Build
-import androidx.appcompat.widget.AppCompatTextView
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -25,10 +23,8 @@ import com.tejpratapsingh.lyricsmaker.R
import com.tejpratapsingh.lyricsmaker.asLyricsApp
import com.tejpratapsingh.lyricsmaker.presentation.notification.NotificationFactory
import com.tejpratapsingh.motion.sdui.infra.SDUIMotionVideoProducerFactory
-import com.tejpratapsingh.motionlib.core.fontSizeH3
import com.tejpratapsingh.motionlib.core.motion.MotionVideoProducer
import com.tejpratapsingh.motionlib.ffmpeg.FfmpegVideoProducerAdapter
-import com.tejpratapsingh.motionlib.ui.custom.text.abstract.AbstractMotionTextView
import com.tejpratapsingh.motionlib.worker.MotionWorker
import com.tejpratapsingh.motionstore.extensions.createProjectFile
import kotlinx.coroutines.Dispatchers
@@ -96,16 +92,7 @@ class LyricsMotionWorker(
return SDUIMotionVideoProducerFactory(
context = appContext,
videoProducerAdapter = FfmpegVideoProducerAdapter(),
- ).createFromProject(motionProject) { views ->
- views.filterIsInstance().forEach {
- it.textView.apply {
- textSize = fontSizeH3
- setTextColor(Color.WHITE)
- setPadding(16, 16, 16, 16)
- textAlignment = AppCompatTextView.TEXT_ALIGNMENT_CENTER
- }
- }
- }
+ ).createFromProject(motionProject)
}
override suspend fun onProgress(
diff --git a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/extensions/ContextExtensions.kt b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/extensions/ContextExtensions.kt
index e493385c..3811c7e0 100644
--- a/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/extensions/ContextExtensions.kt
+++ b/modules/motion-store/src/main/java/com/tejpratapsingh/motionstore/extensions/ContextExtensions.kt
@@ -45,3 +45,16 @@ fun Context.createProjectFile(motionProject: MotionProject): File {
"video_out.mp4",
)
}
+
+/**
+ * Deletes the directory associated with the provided [motionProject] from the internal app storage.
+ *
+ * @param motionProject The project whose folder should be deleted.
+ */
+fun Context.deleteProjectFolder(motionProject: MotionProject) {
+ val projectsDirectory: File = applicationContext.filesDir.resolve(PROJECTS_DIR)
+ val fileDirectory: File = projectsDirectory.resolve(motionProject.id)
+ if (fileDirectory.exists()) {
+ fileDirectory.deleteRecursively()
+ }
+}
diff --git a/modules/motion-video-editor/build.gradle b/modules/motion-video-editor/build.gradle
new file mode 100644
index 00000000..cc89be04
--- /dev/null
+++ b/modules/motion-video-editor/build.gradle
@@ -0,0 +1,53 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace 'com.tejpratapsingh.motioneditor'
+ compileSdk 36
+
+ defaultConfig {
+ minSdk 25
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+ buildFeatures {
+ compose true
+ }
+}
+
+dependencies {
+ implementation project(path: ':modules:core')
+ implementation project(path: ':modules:sdui')
+ implementation project(path: ':modules:motionlib')
+ implementation project(path: ':modules:motion-store')
+ implementation project(path: ':modules:motion-video-player')
+
+ implementation platform(libs.androidx.compose.bom)
+ implementation libs.androidx.ui
+ implementation libs.androidx.ui.graphics
+ implementation libs.androidx.ui.tooling.preview
+ implementation libs.androidx.material3
+ implementation libs.androidx.compose.runtime
+ implementation libs.compose.material.icons.extended
+
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.test.ext.junit
+ androidTestImplementation libs.androidx.test.espresso.core
+ androidTestImplementation platform(libs.androidx.compose.bom)
+ androidTestImplementation libs.androidx.ui.test.junit4
+ debugImplementation libs.androidx.ui.tooling
+}
diff --git a/modules/motion-video-editor/consumer-rules.pro b/modules/motion-video-editor/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/motion-video-editor/proguard-rules.pro b/modules/motion-video-editor/proguard-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/motion-video-editor/src/main/AndroidManifest.xml b/modules/motion-video-editor/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..ad83c6d0
--- /dev/null
+++ b/modules/motion-video-editor/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/TimelineData.kt b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/TimelineData.kt
new file mode 100644
index 00000000..0ee93357
--- /dev/null
+++ b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/TimelineData.kt
@@ -0,0 +1,14 @@
+package com.tejpratapsingh.motioneditor
+
+data class TimelineTrack(
+ val id: String,
+ val items: List
+)
+
+data class TimelineItem(
+ val id: String,
+ val type: String,
+ val startFrame: Int,
+ val endFrame: Int,
+ val label: String
+)
diff --git a/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionEditorScreen.kt b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionEditorScreen.kt
new file mode 100644
index 00000000..a9ec2199
--- /dev/null
+++ b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionEditorScreen.kt
@@ -0,0 +1,205 @@
+package com.tejpratapsingh.motioneditor.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.google.gson.JsonObject
+import com.tejpratapsingh.motion.sdui.infra.SDUIMotionVideoProducerFactory
+import com.tejpratapsingh.motioneditor.utils.TimelineUtils
+import com.tejpratapsingh.motionlib.core.provideCurrentConfig
+import com.tejpratapsingh.motionlib.ui.custom.video.MotionVideoPlayerCompose
+import com.tejpratapsingh.motionstore.tables.MotionProject
+import com.tejpratapsingh.motionstore.tables.SyncTracker
+
+@Composable
+fun MotionEditorScreen(
+ project: MotionProject,
+ onBackClick: () -> Unit,
+ onSaveClick: (MotionProject) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+
+ val producerFactory = remember { SDUIMotionVideoProducerFactory(context) }
+ val motionVideoProducer =
+ remember(project) {
+ producerFactory.createFromProject(project)
+ }
+ val timelineTracks =
+ remember(project) {
+ TimelineUtils.fromSdui(context, project.sdui)
+ }
+
+ var currentFrame by remember { mutableIntStateOf(0) }
+ var timelineHeight by remember { mutableStateOf(300.dp) }
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val maxHeight = maxHeight
+ val minTimelineHeight = 150.dp
+ val maxTimelineHeight = maxHeight - 150.dp
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Video Player at the top
+ MotionVideoPlayerCompose(
+ motionVideoProducer = motionVideoProducer,
+ currentFrame = currentFrame,
+ onFrameChange = { currentFrame = it },
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ )
+
+ // Draggable Handle
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(12.dp)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .pointerInput(density, minTimelineHeight, maxTimelineHeight) {
+ detectDragGestures { change, dragAmount ->
+ change.consume()
+ val dragAmountDp = with(density) { dragAmount.y.toDp() }
+ timelineHeight =
+ (timelineHeight - dragAmountDp).coerceIn(
+ minTimelineHeight,
+ maxTimelineHeight,
+ )
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(40.dp)
+ .height(4.dp)
+ .background(
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ shape = CircleShape,
+ ),
+ )
+ }
+
+ // Timeline at the bottom
+ MotionTimeline(
+ tracks = timelineTracks,
+ currentFrame = currentFrame,
+ totalFrames = motionVideoProducer.totalFrames,
+ onFrameChange = { currentFrame = it },
+ onResize = { dragAmount ->
+ val dragAmountDp = with(density) { dragAmount.toDp() }
+ timelineHeight =
+ (timelineHeight - dragAmountDp).coerceIn(
+ minTimelineHeight,
+ maxTimelineHeight,
+ )
+ },
+ fps = provideCurrentConfig().fps,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(timelineHeight)
+ .navigationBarsPadding(),
+ )
+ }
+
+ // Overlay Back Button
+ IconButton(
+ onClick = onBackClick,
+ modifier =
+ Modifier
+ .statusBarsPadding()
+ .padding(16.dp)
+ .align(Alignment.TopStart)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
+ shape = CircleShape,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = "Back",
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // Overlay Save Button (Tick)
+ IconButton(
+ onClick = { onSaveClick(project) },
+ modifier =
+ Modifier
+ .statusBarsPadding()
+ .padding(16.dp)
+ .align(Alignment.TopEnd)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
+ shape = CircleShape,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = "Save",
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewMotionEditorScreen() {
+ val sampleProject =
+ MotionProject(
+ id = "sample_project",
+ name = "Sample Project",
+ path = "/sample_project",
+ sdui =
+ JsonObject().apply {
+ // Minimal SDUI structure to satisfy factory/utils if needed
+ },
+ syncTracker = SyncTracker(updatedBy = "preview_device"),
+ )
+
+ MaterialTheme {
+ Surface {
+ MotionEditorScreen(
+ project = sampleProject,
+ onBackClick = {},
+ onSaveClick = {},
+ )
+ }
+ }
+}
diff --git a/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionTimeline.kt b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionTimeline.kt
new file mode 100644
index 00000000..a01ed90d
--- /dev/null
+++ b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/ui/MotionTimeline.kt
@@ -0,0 +1,344 @@
+package com.tejpratapsingh.motioneditor.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+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.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Surface
+import androidx.compose.ui.text.style.TextOverflow
+import com.tejpratapsingh.motioneditor.TimelineItem
+import com.tejpratapsingh.motioneditor.TimelineTrack
+import androidx.compose.ui.input.pointer.pointerInput
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+@Composable
+fun MotionTimeline(
+ tracks: List,
+ currentFrame: Int,
+ totalFrames: Int,
+ onFrameChange: (Int) -> Unit,
+ onResize: (Float) -> Unit = {},
+ fps: Int = 30,
+ pixelsPerFrame: Float = 5f,
+ modifier: Modifier = Modifier,
+) {
+ val horizontalScrollState = rememberScrollState()
+ val verticalScrollState = rememberScrollState()
+ val density = LocalDensity.current
+ val haptic = LocalHapticFeedback.current
+ val view = LocalView.current
+ var lastFeedbackTime by remember { mutableLongStateOf(0L) }
+
+ BoxWithConstraints(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
+ ) {
+ val viewportWidth = constraints.maxWidth
+ val halfWidth = with(density) { (viewportWidth / 2).toDp() }
+
+ // Sync scroll position with currentFrame (when not scrolling manually)
+ LaunchedEffect(currentFrame, pixelsPerFrame) {
+ if (!horizontalScrollState.isScrollInProgress) {
+ val currentX = (currentFrame * pixelsPerFrame * density.density)
+ horizontalScrollState.scrollTo(currentX.toInt())
+ }
+ }
+
+ // Sync currentFrame with scroll position (when scrolling manually)
+ LaunchedEffect(horizontalScrollState, pixelsPerFrame, density, totalFrames, onFrameChange) {
+ snapshotFlow { horizontalScrollState.value }
+ .map { (it / (pixelsPerFrame * density.density)).toInt().coerceIn(0, totalFrames) }
+ .distinctUntilChanged()
+ .collectLatest { newFrame ->
+ if (horizontalScrollState.isScrollInProgress) {
+ onFrameChange(newFrame)
+
+ val currentTime = System.currentTimeMillis()
+ if (currentTime - lastFeedbackTime >= 50L) {
+ haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ view.playSoundEffect(android.view.SoundEffectConstants.CLICK)
+ lastFeedbackTime = currentTime
+ }
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ // Time Scale (Ruler) - Scrolls horizontally with tracks but fixed at top
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(40.dp)
+ .pointerInput(onResize) {
+ detectVerticalDragGestures { change, dragAmount ->
+ change.consume()
+ onResize(dragAmount)
+ }
+ }
+ .horizontalScroll(horizontalScrollState),
+ ) {
+ Row {
+ Spacer(modifier = Modifier.width(halfWidth))
+ TimeScaleView(
+ totalFrames = totalFrames,
+ fps = fps,
+ pixelsPerFrame = pixelsPerFrame,
+ )
+ Spacer(modifier = Modifier.width(halfWidth))
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .horizontalScroll(horizontalScrollState)
+ .verticalScroll(verticalScrollState)
+ .padding(vertical = 16.dp),
+ ) {
+ tracks.forEach { track ->
+ Row {
+ Spacer(modifier = Modifier.width(halfWidth))
+ TimelineTrackView(track, pixelsPerFrame, fps)
+ Spacer(modifier = Modifier.width(halfWidth))
+ }
+ }
+ }
+
+ // Fixed Progress Marker (Always in the middle)
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.Center)
+ .width(2.dp)
+ .fillMaxHeight()
+ .background(Color.Red),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun TimeScaleView(
+ totalFrames: Int,
+ fps: Int,
+ pixelsPerFrame: Float,
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+ val totalWidth = (totalFrames * pixelsPerFrame).dp
+
+ Box(
+ modifier =
+ modifier
+ .width(totalWidth)
+ .height(40.dp),
+ ) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val strokeWidth = 1.dp.toPx()
+ val majorTickHeight = 15.dp.toPx()
+ val minorTickHeight = 8.dp.toPx()
+
+ for (frame in 0..totalFrames) {
+ val x = frame * pixelsPerFrame * density.density
+ val isMajorTick = frame % fps == 0
+ val tickHeight = if (isMajorTick) majorTickHeight else minorTickHeight
+ val color = if (isMajorTick) Color.Gray else Color.LightGray
+
+ drawLine(
+ color = color,
+ start = Offset(x, size.height),
+ end = Offset(x, size.height - tickHeight),
+ strokeWidth = strokeWidth,
+ )
+ }
+ }
+
+ // Labels for major ticks
+ for (frame in 0..totalFrames step fps) {
+ val x = (frame * pixelsPerFrame).dp
+ Text(
+ text = formatFrameToTime(frame, fps),
+ modifier =
+ Modifier
+ .offset(x = x + 4.dp, y = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+private fun formatFrameToTime(
+ frame: Int,
+ fps: Int,
+): String {
+ val totalSeconds = frame / fps
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return "%02d:%02d".format(minutes, seconds)
+}
+
+@Composable
+fun TimelineTrackView(
+ track: TimelineTrack,
+ pixelsPerFrame: Float,
+ fps: Int,
+) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(60.dp)
+ .padding(vertical = 4.dp)
+ .background(Color.DarkGray.copy(alpha = 0.1f)),
+ ) {
+ track.items.forEach { item ->
+ TimelineItemView(item, pixelsPerFrame, fps)
+ }
+ }
+}
+
+@Composable
+fun TimelineItemView(
+ item: TimelineItem,
+ pixelsPerFrame: Float,
+ fps: Int,
+) {
+ val startPx = (item.startFrame * pixelsPerFrame).dp
+ val widthPx = ((item.endFrame - item.startFrame) * pixelsPerFrame).dp
+
+ Box(
+ modifier =
+ Modifier
+ .offset(x = startPx)
+ .width(widthPx)
+ .height(52.dp)
+ .background(MaterialTheme.colorScheme.primary, shape = MaterialTheme.shapes.small)
+ .padding(horizontal = 8.dp),
+ contentAlignment = androidx.compose.ui.Alignment.CenterStart,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // "Icon" - First character
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f),
+ modifier = Modifier.size(28.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = item.label.firstOrNull()?.toString()?.uppercase() ?: "?",
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Column {
+ Text(
+ text = item.label,
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = "${formatFrameToTime(item.startFrame, fps)} - ${formatFrameToTime(item.endFrame, fps)}",
+ color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewMotionTimeline() {
+ val sampleTracks =
+ listOf(
+ TimelineTrack(
+ id = "1",
+ items =
+ listOf(
+ TimelineItem("1", "Image", 0, 50, "Background Image"),
+ ),
+ ),
+ TimelineTrack(
+ id = "2",
+ items =
+ listOf(
+ TimelineItem("2", "Text", 10, 40, "Hello World"),
+ ),
+ ),
+ TimelineTrack(
+ id = "3",
+ items =
+ listOf(
+ TimelineItem("3", "Effect", 20, 60, "Fade In"),
+ ),
+ ),
+ )
+
+ MaterialTheme {
+ MotionTimeline(
+ tracks = sampleTracks,
+ currentFrame = 25,
+ totalFrames = 100,
+ fps = 30,
+ onFrameChange = {},
+ )
+ }
+}
diff --git a/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/utils/TimelineUtils.kt b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/utils/TimelineUtils.kt
new file mode 100644
index 00000000..06b86cff
--- /dev/null
+++ b/modules/motion-video-editor/src/main/java/com/tejpratapsingh/motioneditor/utils/TimelineUtils.kt
@@ -0,0 +1,34 @@
+package com.tejpratapsingh.motioneditor.utils
+
+import android.content.Context
+import com.google.gson.JsonObject
+import com.tejpratapsingh.motion.sdui.infra.getMotionViews
+import com.tejpratapsingh.motioneditor.TimelineItem
+import com.tejpratapsingh.motioneditor.TimelineTrack
+import com.tejpratapsingh.motionlib.core.MotionView
+
+object TimelineUtils {
+ fun fromSdui(context: Context, sduiJson: JsonObject): List {
+ val views = sduiJson.getMotionViews(context)
+ return fromMotionViews(views)
+ }
+
+ fun fromMotionViews(views: List): List {
+ // Simple mapping: each view gets its own track for now
+ // A more advanced implementation might stack non-overlapping views on the same track
+ return views.mapIndexed { index, view ->
+ TimelineTrack(
+ id = "track_$index",
+ items = listOf(
+ TimelineItem(
+ id = view.hashCode().toString(),
+ type = view.javaClass.simpleName,
+ startFrame = view.startFrame,
+ endFrame = view.endFrame,
+ label = view.javaClass.simpleName
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt b/modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt
index 4b756cd9..285f9c2e 100644
--- a/modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt
+++ b/modules/motion-video-player/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayerCompose.kt
@@ -12,7 +12,10 @@ 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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
@@ -47,43 +50,65 @@ import java.util.Locale
fun MotionVideoPlayerCompose(
motionVideoProducer: MotionVideoProducer,
modifier: Modifier = Modifier,
+ currentFrame: Int? = null,
+ isPlaying: Boolean? = null,
+ showControls: Boolean = true,
+ onFrameChange: (Int) -> Unit = {},
+ onPlayingChange: (Boolean) -> Unit = {},
) {
val context = LocalContext.current
val motionConfig: MotionConfig = remember { provideCurrentConfig() }
val totalFrames = motionVideoProducer.totalFrames
- var currentFrame by remember { mutableIntStateOf(0) }
- var isPlaying by remember { mutableStateOf(false) }
+ var internalCurrentFrame by remember { mutableIntStateOf(0) }
+ val effectiveCurrentFrame = currentFrame ?: internalCurrentFrame
+
+ var internalIsPlaying by remember { mutableStateOf(false) }
+ val effectiveIsPlaying = isPlaying ?: internalIsPlaying
+
var previewBitmap by remember { mutableStateOf(null) }
val activePlayers = remember { mutableStateMapOf() }
- // Update preview when currentFrame changes
- LaunchedEffect(currentFrame) {
- motionVideoProducer.motionComposerView.forFrame(currentFrame)
+ // Update preview when effectiveCurrentFrame changes
+ LaunchedEffect(motionVideoProducer, effectiveCurrentFrame) {
+ motionVideoProducer.motionComposerView.forFrame(effectiveCurrentFrame)
previewBitmap = motionVideoProducer.motionComposerView.getViewBitmap()
}
// Playback loop
- LaunchedEffect(isPlaying) {
- if (isPlaying) {
+ LaunchedEffect(effectiveIsPlaying, motionVideoProducer, totalFrames, motionConfig, onFrameChange, currentFrame) {
+ if (effectiveIsPlaying) {
val frameDurationMs = (1000.0 / motionConfig.fps).toLong()
var lastFrameTime = System.currentTimeMillis()
+ var currentFrameTracker = effectiveCurrentFrame
- while (isPlaying) {
+ while (effectiveIsPlaying) {
val currentTime = System.currentTimeMillis()
val elapsed = currentTime - lastFrameTime
if (elapsed >= frameDurationMs) {
val framesToAdvance = (elapsed / frameDurationMs).toInt().coerceAtLeast(1)
- val nextFrame = currentFrame + framesToAdvance
+ currentFrameTracker += framesToAdvance
- if (nextFrame <= totalFrames) {
- currentFrame = nextFrame
+ if (currentFrameTracker <= totalFrames) {
+ if (currentFrame != null) {
+ onFrameChange(currentFrameTracker)
+ } else {
+ internalCurrentFrame = currentFrameTracker
+ }
} else {
// Loop
- currentFrame = 0
- activePlayers.values.forEach { it.stop(); it.release() }
+ currentFrameTracker = 0
+ if (currentFrame != null) {
+ onFrameChange(0)
+ } else {
+ internalCurrentFrame = 0
+ }
+ activePlayers.values.forEach {
+ it.stop()
+ it.release()
+ }
activePlayers.clear()
}
lastFrameTime = currentTime
@@ -94,19 +119,20 @@ fun MotionVideoPlayerCompose(
}
// Audio management
- LaunchedEffect(currentFrame, isPlaying) {
- if (isPlaying) {
+ LaunchedEffect(effectiveCurrentFrame, effectiveIsPlaying, motionVideoProducer) {
+ if (effectiveIsPlaying) {
motionVideoProducer.motionAudio.forEach { audio ->
- val shouldPlay = currentFrame in audio.delayFrame..audio.endFrame
+ val shouldPlay = effectiveCurrentFrame in audio.delayFrame..audio.endFrame
val player = activePlayers[audio]
if (shouldPlay) {
if (player == null) {
- val mediaPlayer = MediaPlayer().apply {
- setDataSource(audio.file.absolutePath)
- prepare()
- start()
- }
+ val mediaPlayer =
+ MediaPlayer().apply {
+ setDataSource(audio.file.absolutePath)
+ prepare()
+ start()
+ }
activePlayers[audio] = mediaPlayer
} else if (!player.isPlaying) {
player.start()
@@ -126,7 +152,9 @@ fun MotionVideoPlayerCompose(
DisposableEffect(Unit) {
onDispose {
- isPlaying = false
+ if (isPlaying == null) {
+ internalIsPlaying = false
+ }
activePlayers.values.forEach {
if (it.isPlaying) it.stop()
it.release()
@@ -136,71 +164,149 @@ fun MotionVideoPlayerCompose(
}
Column(
- modifier = modifier
- .fillMaxSize()
- .background(Color.Black),
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
// Preview Area
Box(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
- contentAlignment = Alignment.Center
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
) {
previewBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Video Preview",
- modifier = Modifier
- .fillMaxSize()
- .aspectRatio(
- motionConfig.aspectRatio.width.toFloat() / motionConfig.aspectRatio.height.toFloat()
- ),
- contentScale = ContentScale.Fit
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .aspectRatio(
+ motionConfig.aspectRatio.width.toFloat() / motionConfig.aspectRatio.height.toFloat(),
+ ),
+ contentScale = ContentScale.Fit,
)
}
}
// Controls Area
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
- .padding(16.dp)
- ) {
- Slider(
- value = currentFrame.toFloat(),
- onValueChange = {
- currentFrame = it.toInt()
- isPlaying = false // Pause on seek
- },
- valueRange = 0f..totalFrames.toFloat(),
- modifier = Modifier.fillMaxWidth()
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
+ if (showControls) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
+ .padding(16.dp),
) {
- IconButton(onClick = { isPlaying = !isPlaying }) {
- Icon(
- imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = if (isPlaying) "Pause" else "Play"
+ Slider(
+ value = effectiveCurrentFrame.toFloat(),
+ onValueChange = {
+ if (currentFrame != null) {
+ onFrameChange(it.toInt())
+ } else {
+ internalCurrentFrame = it.toInt()
+ }
+ if (isPlaying == null) {
+ internalIsPlaying = false
+ onPlayingChange(false)
+ } else {
+ onPlayingChange(false)
+ }
+ },
+ valueRange = 0f..totalFrames.toFloat(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ IconButton(onClick = {
+ val nextFrame = (effectiveCurrentFrame - 1).coerceAtLeast(0)
+ if (currentFrame != null) {
+ onFrameChange(nextFrame)
+ } else {
+ internalCurrentFrame = nextFrame
+ }
+ if (isPlaying == null) {
+ internalIsPlaying = false
+ onPlayingChange(false)
+ } else {
+ onPlayingChange(false)
+ }
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Previous Frame",
+ )
+ }
+
+ IconButton(
+ onClick = {
+ if (isPlaying == null) {
+ internalIsPlaying = !internalIsPlaying
+ onPlayingChange(internalIsPlaying)
+ } else {
+ onPlayingChange(!isPlaying)
+ }
+ },
+ modifier =
+ Modifier.background(
+ color = MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(8.dp),
+ ),
+ ) {
+ Icon(
+ imageVector = if (effectiveIsPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (effectiveIsPlaying) "Pause" else "Play",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+
+ IconButton(onClick = {
+ val nextFrame = (effectiveCurrentFrame + 1).coerceAtMost(totalFrames)
+ if (currentFrame != null) {
+ onFrameChange(nextFrame)
+ } else {
+ internalCurrentFrame = nextFrame
+ }
+ if (isPlaying == null) {
+ internalIsPlaying = false
+ onPlayingChange(false)
+ } else {
+ onPlayingChange(false)
+ }
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+ contentDescription = "Next Frame",
+ )
+ }
+ }
+
+ Text(
+ text = "${formatTime(effectiveCurrentFrame, motionConfig.fps)} / ${formatTime(totalFrames, motionConfig.fps)}",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.align(Alignment.CenterEnd),
)
}
-
- Text(
- text = "${formatTime(currentFrame, motionConfig.fps)} / ${formatTime(totalFrames, motionConfig.fps)}",
- style = MaterialTheme.typography.bodyMedium
- )
}
}
}
}
-private fun formatTime(frames: Int, fps: Int): String {
+private fun formatTime(
+ frames: Int,
+ fps: Int,
+): String {
val totalSeconds = frames / fps
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
diff --git a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/background/TranslucentMotionView.kt b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/background/TranslucentMotionView.kt
new file mode 100644
index 00000000..9d00585d
--- /dev/null
+++ b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/background/TranslucentMotionView.kt
@@ -0,0 +1,37 @@
+package com.tejpratapsingh.motionlib.ui.custom.background
+
+import android.content.Context
+import androidx.core.graphics.toColorInt
+import com.tejpratapsingh.motionlib.core.MotionEffect
+import com.tejpratapsingh.motionlib.core.MotionView
+import com.tejpratapsingh.motionlib.core.motion.BaseContourMotionView
+import com.tejpratapsingh.motionlib.core.provideCurrentConfig
+
+class TranslucentMotionView(
+ context: Context,
+ val color: String,
+ alpha: Float = 1.0f,
+ startFrame: Int,
+ endFrame: Int,
+ effects: List = emptyList(),
+) : BaseContourMotionView(context, startFrame, endFrame, effects = effects) {
+ init {
+ setBackgroundColor(color.toColorInt())
+ this.alpha = alpha
+ contourHeightOf {
+ provideCurrentConfig()
+ .aspectRatio.height
+ .toYInt()
+ }
+ contourWidthOf {
+ provideCurrentConfig()
+ .aspectRatio.width
+ .toXInt()
+ }
+ }
+
+ override fun forFrame(frame: Int): MotionView {
+ super.forFrame(frame)
+ return this
+ }
+}
diff --git a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/AccentMiddlePopUpTextView.kt b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/AccentMiddlePopUpTextView.kt
index 7790b897..0b09bbac 100644
--- a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/AccentMiddlePopUpTextView.kt
+++ b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/AccentMiddlePopUpTextView.kt
@@ -70,7 +70,7 @@ class AccentMiddlePopUpTextView(
override fun forFrame(frame: Int): MotionView {
super.forFrame(frame)
-
+
val progress: Float =
MotionInterpolator
.interpolateForRange(
@@ -85,7 +85,7 @@ class AccentMiddlePopUpTextView(
var currentIdx = 0
wordArray.forEachIndexed { index, word ->
val wordProgress = (progress - index).coerceIn(0f, 1f)
-
+
val color = if (index == middleIndex) accentColor else textView.currentTextColor
val span =
@@ -156,7 +156,7 @@ class AccentMiddlePopUpTextView(
val oldAlpha = paint.alpha
val oldColor = paint.color
-
+
paint.alpha = alpha.toInt()
canvas.withTranslation(y = translationY) {
diff --git a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/abstract/AbstractMotionTextView.kt b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/abstract/AbstractMotionTextView.kt
index 09d65933..9a158ffb 100644
--- a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/abstract/AbstractMotionTextView.kt
+++ b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/text/abstract/AbstractMotionTextView.kt
@@ -12,6 +12,23 @@ import com.tejpratapsingh.motionlib.core.motion.BaseContourMotionView
import com.tejpratapsingh.motionlib.core.provideCurrentConfig
import com.tejpratapsingh.motionlib.utils.getWebFont
+/**
+ * An abstract base class for motion-based text views.
+ * Extends [BaseContourMotionView] to provide common functionality for rendering text with motion effects,
+ * custom fonts, and styling within a contour layout.
+ *
+ * @param context The Android context.
+ * @param text The text to be displayed.
+ * @param startFrame The frame index where the text starts appearing.
+ * @param endFrame The frame index where the text stops appearing. If -1, it stays indefinitely or until the end of the video.
+ * @param textView The underlying [AppCompatTextView] used for rendering.
+ * @param writingSpeed The speed factor for text "writing" animations. Defaults to 1.0f.
+ * @param fontUrl Optional URL for a custom web font.
+ * @param textSizeVariant Optional variant for text size (e.g., TITLE, BODY).
+ * @param textColor Optional hex color string for the text.
+ * @param highlightColor Optional hex color string for highlighting parts of the text.
+ * @param effects A list of [MotionEffect]s to be applied to this view.
+ */
abstract class AbstractMotionTextView(
context: Context,
val text: String,
@@ -25,6 +42,11 @@ abstract class AbstractMotionTextView(
val highlightColor: String? = null,
effects: List = emptyList(),
) : BaseContourMotionView(context, startFrame, endFrame, effects = effects) {
+
+ /**
+ * Calculates the adjusted end frame based on the writing speed.
+ * If [writingSpeed] is 1.0, it matches [endFrame]. If higher, the duration is shortened.
+ */
protected val inferredEndFrame: Int =
if (endFrame != -1 && writingSpeed > 0) {
(startFrame + (endFrame - startFrame) / writingSpeed).toInt()
@@ -33,6 +55,7 @@ abstract class AbstractMotionTextView(
}
init {
+ // Set the contour dimensions based on the current configuration's aspect ratio.
contourHeightOf {
provideCurrentConfig()
.aspectRatio.height
@@ -44,8 +67,10 @@ abstract class AbstractMotionTextView(
.toXInt()
}
+ // Center the text within the TextView.
textView.gravity = Gravity.CENTER
+ // Apply text styling and custom fonts.
textView.apply {
textSizeVariant?.let { variant ->
val config = provideCurrentConfig()
@@ -56,7 +81,7 @@ abstract class AbstractMotionTextView(
try {
this.setTextColor(it.toColorInt())
} catch (e: Exception) {
- // Fallback or log error
+ // Fallback or log error if the color string is invalid
}
}
if (fontUrl != null) {
@@ -64,6 +89,7 @@ abstract class AbstractMotionTextView(
}
}
+ // Position the TextView to fill the parent container using Contour.
textView.layoutBy(
x =
leftTo {
@@ -78,6 +104,7 @@ abstract class AbstractMotionTextView(
parent.bottom()
},
)
+ // Set the final text content.
textView.text = text
}
}
diff --git a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayer.kt b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayer.kt
index 524ac52b..deb04400 100644
--- a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayer.kt
+++ b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/ui/custom/video/MotionVideoPlayer.kt
@@ -206,14 +206,15 @@ class MotionVideoPlayer(
playbackJob =
scope.launch {
var startTime = SystemClock.elapsedRealtime()
- var startFrame = seekBar.progress
+ var startFrameValue = seekBar.progress
+ var lastRenderedFrame = startFrameValue
while (isPlaying) {
val elapsed = SystemClock.elapsedRealtime() - startTime
- val expectedFrame = startFrame + (elapsed / frameDurationMs).toInt()
+ val expectedFrame = startFrameValue + (elapsed / frameDurationMs).toInt()
if (expectedFrame <= seekBar.max) {
- if (expectedFrame != seekBar.progress) {
+ if (expectedFrame != lastRenderedFrame) {
motionVideoProducer.motionComposerView.forFrame(expectedFrame)
// 🔊 Check if we should play audio
@@ -223,6 +224,7 @@ class MotionVideoPlayer(
)
seekBar.progress = expectedFrame
+ lastRenderedFrame = expectedFrame
}
} else {
// Loop
@@ -233,7 +235,8 @@ class MotionVideoPlayer(
// Reset timer for the next loop iteration
startTime = SystemClock.elapsedRealtime()
- startFrame = resetFrame
+ startFrameValue = resetFrame
+ lastRenderedFrame = resetFrame
}
delay(10) // Check frequently enough for smooth playback but avoid 100% CPU usage
}
diff --git a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/utils/ImageUtil.kt b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/utils/ImageUtil.kt
index fb347419..2bc18bf5 100644
--- a/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/utils/ImageUtil.kt
+++ b/modules/motionlib/src/main/java/com/tejpratapsingh/motionlib/utils/ImageUtil.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
+import com.tejpratapsingh.motionlib.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -14,16 +15,26 @@ object ImageUtil {
private val client = OkHttpClient()
suspend fun fetchBitmap(context: Context, uri: Uri): Bitmap? = withContext(Dispatchers.IO) {
- return@withContext when (uri.scheme) {
+ val bitmap = when (uri.scheme) {
"http", "https" -> fetchFromNetwork(uri.toString())
"content", "file", "android.resource" -> fetchFromLocal(context, uri)
else -> null
}
+ return@withContext bitmap ?: fetchDefault(context)
+ }
+
+ private fun fetchDefault(context: Context): Bitmap? {
+ return try {
+ BitmapFactory.decodeResource(context.resources, R.drawable.default_bg)
+ } catch (e: Exception) {
+ null
+ }
}
private fun fetchFromNetwork(url: String): Bitmap? {
- val request = Request.Builder().url(url).build()
return try {
+ if (url.isBlank()) return null
+ val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val bytes = response.body()?.bytes() ?: return null
diff --git a/modules/motionlib/src/main/res/drawable/default_bg.jpg b/modules/motionlib/src/main/res/drawable/default_bg.jpg
new file mode 100644
index 00000000..a282007b
Binary files /dev/null and b/modules/motionlib/src/main/res/drawable/default_bg.jpg differ
diff --git a/modules/sdui/src/main/java/com/tejpratapsingh/motion/sdui/infra/MotionSduiInitializer.kt b/modules/sdui/src/main/java/com/tejpratapsingh/motion/sdui/infra/MotionSduiInitializer.kt
index 16aab7cd..a68f288d 100644
--- a/modules/sdui/src/main/java/com/tejpratapsingh/motion/sdui/infra/MotionSduiInitializer.kt
+++ b/modules/sdui/src/main/java/com/tejpratapsingh/motion/sdui/infra/MotionSduiInitializer.kt
@@ -13,6 +13,7 @@ import com.tejpratapsingh.motionlib.ui.custom.audio.CircularAudioWaveformView
import com.tejpratapsingh.motionlib.ui.custom.audio.RadialAudioWaveformView
import com.tejpratapsingh.motionlib.ui.custom.background.GradientView
import com.tejpratapsingh.motionlib.ui.custom.background.Orientation
+import com.tejpratapsingh.motionlib.ui.custom.background.TranslucentMotionView
import com.tejpratapsingh.motionlib.ui.custom.image.CircularMotionImageView
import com.tejpratapsingh.motionlib.ui.custom.image.MotionImageView
import com.tejpratapsingh.motionlib.ui.custom.text.AccentMiddlePopUpTextView
@@ -268,7 +269,7 @@ object MotionSduiInitializer {
val props = json.parseMotionViewProps()
val imageUriStr =
json.get("imageUri")?.asString
- ?: throw IllegalArgumentException("imageUri required for CircularMotionImageView")
+ ?: ""
CircularMotionImageView(
context = context,
imageUri = imageUriStr.toUri(),
@@ -287,7 +288,7 @@ object MotionSduiInitializer {
val props = json.parseMotionViewProps()
val imageUriStr =
json.get("imageUri")?.asString
- ?: throw IllegalArgumentException("imageUri required for MotionImageView")
+ ?: ""
MotionImageView(
context = context,
imageUri = imageUriStr.toUri(),
@@ -359,6 +360,28 @@ object MotionSduiInitializer {
json.add("colors", colorsArray)
}
+ // Register TranslucentMotionView
+ MotionSdui.registerView(TranslucentMotionView::class.java.simpleName) { context, json ->
+ val props = json.parseMotionViewProps()
+ val color = json.get("color")?.asString ?: "#00000000"
+ val alpha = json.get("alpha")?.asFloat ?: 1.0f
+ TranslucentMotionView(
+ context = context,
+ color = color,
+ alpha = alpha,
+ startFrame = props.startFrame,
+ endFrame = props.endFrame,
+ effects = props.effects,
+ ).apply {
+ this.layoutInfo = props.layoutInfo
+ }
+ }
+ MotionSdui.registerViewSerializer(TranslucentMotionView::class.java) { view, json ->
+ json.addProperty("type", view.javaClass.simpleName)
+ json.addProperty("color", view.color)
+ json.addProperty("alpha", view.alpha)
+ }
+
// Register CircularAudioWaveformView
MotionSdui.registerView(CircularAudioWaveformView::class.java.simpleName) { context, json ->
val props = json.parseMotionViewProps()
diff --git a/modules/sdui/src/test/java/com/tejpratapsingh/motion/sdui/MotionSduiRegistryTest.kt b/modules/sdui/src/test/java/com/tejpratapsingh/motion/sdui/MotionSduiRegistryTest.kt
index b8a2e4e5..42e58226 100644
--- a/modules/sdui/src/test/java/com/tejpratapsingh/motion/sdui/MotionSduiRegistryTest.kt
+++ b/modules/sdui/src/test/java/com/tejpratapsingh/motion/sdui/MotionSduiRegistryTest.kt
@@ -4,6 +4,7 @@ import com.google.gson.JsonObject
import com.tejpratapsingh.motion.sdui.infra.MotionSduiInitializer
import com.tejpratapsingh.motion.sdui.infra.toMotionView
import com.tejpratapsingh.motionlib.ui.custom.background.GradientView
+import com.tejpratapsingh.motionlib.ui.custom.background.TranslucentMotionView
import com.tejpratapsingh.motionlib.ui.custom.text.TransparentTextView
import com.tejpratapsingh.motionlib.ui.custom.text.TypeWriterTextView
import org.junit.Assert.assertTrue
@@ -59,4 +60,19 @@ class MotionSduiRegistryTest {
val view = json.toMotionView(RuntimeEnvironment.getApplication())
assertTrue(view is GradientView)
}
+
+ @Test
+ fun testTranslucentMotionViewRegistration() {
+ val json =
+ JsonObject().apply {
+ addProperty("type", "TranslucentMotionView")
+ addProperty("color", "#FF0000")
+ addProperty("alpha", 0.5f)
+ addProperty("startFrame", 0)
+ addProperty("endFrame", 100)
+ }
+ val view = json.toMotionView(RuntimeEnvironment.getApplication())
+ assertTrue(view is TranslucentMotionView)
+ assertTrue((view as TranslucentMotionView).alpha == 0.5f)
+ }
}
diff --git a/modules/templates/src/androidTest/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProviderTest.kt b/modules/templates/src/androidTest/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProviderTest.kt
new file mode 100644
index 00000000..88a2e1e0
--- /dev/null
+++ b/modules/templates/src/androidTest/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProviderTest.kt
@@ -0,0 +1,62 @@
+package com.tejpratapsingh.motionlib.templates.sdui
+
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.tejpratapsingh.motionlib.templates.dsl.motionTemplate
+import com.tejpratapsingh.motionlib.templates.extensions.popUpTextView
+import com.tejpratapsingh.motionlib.templates.model.TemplateData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+class MotionTemplateSDUIProviderTest {
+
+ @Test
+ fun testProvideSDUI() {
+ val appContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ val template = motionTemplate("Test Template") {
+ parameters {
+ string("title")
+ }
+ content {
+ val title = data.getString("title") ?: "Default"
+ popUpTextView(
+ text = title,
+ startFrame = 0,
+ endFrame = 100
+ )
+ audio(File(context.cacheDir, "test.mp3"), startFrame = 0, endFrame = 100)
+ }
+ }
+
+ val data = TemplateData(mapOf(
+ "title" to "Hello SDUI"
+ ))
+
+ val sdui = MotionTemplateSDUIProvider.provideSDUI(
+ context = appContext,
+ template = template,
+ data = data
+ )
+
+ assertNotNull(sdui)
+ assertTrue(sdui.has("views"))
+ assertTrue(sdui.has("audios"))
+ assertTrue(sdui.has("config"))
+
+ val viewsArray = sdui.getAsJsonArray("views")
+ assertEquals(1, viewsArray.size())
+
+ val firstView = viewsArray[0].asJsonObject
+ assertTrue(firstView.has("type"))
+
+ val audiosArray = sdui.getAsJsonArray("audios")
+ assertEquals(1, audiosArray.size())
+ }
+}
diff --git a/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/extensions/BackgroundExtensions.kt b/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/extensions/BackgroundExtensions.kt
index bc545efc..1ee9c66a 100644
--- a/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/extensions/BackgroundExtensions.kt
+++ b/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/extensions/BackgroundExtensions.kt
@@ -5,6 +5,7 @@ import com.tejpratapsingh.motionlib.core.MotionLayoutInfo
import com.tejpratapsingh.motionlib.templates.dsl.ContentScope
import com.tejpratapsingh.motionlib.ui.custom.background.GradientView
import com.tejpratapsingh.motionlib.ui.custom.background.Orientation
+import com.tejpratapsingh.motionlib.ui.custom.background.TranslucentMotionView
fun ContentScope.gradientView(
startFrame: Int,
@@ -18,3 +19,16 @@ fun ContentScope.gradientView(
.apply { this.layoutInfo = layoutInfo }
.apply { block?.invoke(this) }
.also { addView(it) }
+
+fun ContentScope.translucentMotionView(
+ color: String,
+ alpha: Float = 1.0f,
+ startFrame: Int,
+ endFrame: Int,
+ effects: List = emptyList(),
+ layoutInfo: MotionLayoutInfo = MotionLayoutInfo(),
+ block: (TranslucentMotionView.() -> Unit)? = null,
+) = TranslucentMotionView(context, color, alpha, startFrame, endFrame, effects = effects)
+ .apply { this.layoutInfo = layoutInfo }
+ .apply { block?.invoke(this) }
+ .also { addView(it) }
diff --git a/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProvider.kt b/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProvider.kt
new file mode 100644
index 00000000..798a65c4
--- /dev/null
+++ b/modules/templates/src/main/java/com/tejpratapsingh/motionlib/templates/sdui/MotionTemplateSDUIProvider.kt
@@ -0,0 +1,66 @@
+package com.tejpratapsingh.motionlib.templates.sdui
+
+import android.content.Context
+import com.google.gson.JsonObject
+import com.tejpratapsingh.motion.sdui.infra.createMotionSDUIJson
+import com.tejpratapsingh.motionlib.core.MotionConfig
+import com.tejpratapsingh.motionlib.core.MotionView
+import com.tejpratapsingh.motionlib.core.motion.MotionVideoProducer
+import com.tejpratapsingh.motionlib.core.provideCurrentConfig
+import com.tejpratapsingh.motionlib.core.setCurrentConfig
+import com.tejpratapsingh.motionlib.templates.dsl.ContentScope
+import com.tejpratapsingh.motionlib.templates.model.MotionTemplate
+import com.tejpratapsingh.motionlib.templates.model.TemplateData
+
+/**
+ * Provider to generate SDUI JSON from [MotionTemplate].
+ */
+object MotionTemplateSDUIProvider {
+ /**
+ * Generate SDUI JSON from [MotionTemplate].
+ *
+ * @param context [Context] to use for building template.
+ * @param template [MotionTemplate] to generate SDUI from.
+ * @param data [TemplateData] to apply to template.
+ * @param config [MotionConfig] to use for template. If null, current config will be used.
+ * @return [JsonObject] representing SDUI.
+ */
+ fun provideSDUI(
+ context: Context,
+ template: MotionTemplate,
+ data: TemplateData,
+ config: MotionConfig? = null,
+ ): JsonObject {
+ val motionConfig = config ?: provideCurrentConfig()
+ setCurrentConfig(motionConfig)
+
+ val producer =
+ MotionVideoProducer.with(
+ context = context,
+ )
+
+ val contentScope =
+ ContentScope(
+ context = context,
+ producer = producer,
+ data = data,
+ )
+
+ template.buildContent(contentScope)
+
+ val views = mutableListOf()
+ for (i in 0 until producer.motionComposerView.childCount) {
+ val child = producer.motionComposerView.getChildAt(i)
+ if (child is MotionView) {
+ views.add(child)
+ }
+ }
+
+ return createMotionSDUIJson(
+ views = views,
+ audios = producer.motionAudio,
+ plugins = producer.motionComposerView.plugins,
+ config = motionConfig,
+ )
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index cc6c5b31..3bc1dde0 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -33,3 +33,4 @@ include ':modules:sdui'
include ':modules:metadata-extractor'
include ':modules:motion-store'
include ':modules:motion-video-player'
+include ':modules:motion-video-editor'