From 1dead52375c24fe3deba2cf4439135edced93221 Mon Sep 17 00:00:00 2001 From: Secozzi <49240133+Secozzi@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:23:33 +0100 Subject: [PATCH] feat(player): Add option to create and use custom buttons (#132) * feat(player): Add option to create and use custom buttons * move to singleOf and viewModelOf --- app/src/main/java/live/mehiz/mpvkt/App.kt | 2 + .../live/mehiz/mpvkt/database/Migrations.kt | 17 ++ .../mehiz/mpvkt/database/MpvKtDatabase.kt | 5 +- .../mpvkt/database/dao/CustomButtonDao.kt | 68 ++++++ .../database/entities/CustomButtonEntity.kt | 13 ++ .../repository/CustomButtonRepositoryImpl.kt | 34 +++ .../live/mehiz/mpvkt/di/DatabaseModule.kt | 4 + .../java/live/mehiz/mpvkt/di/ViewModel.kt | 9 + .../repository/CustomButtonRepository.kt | 18 ++ .../custombuttons/CustomButtonsScreen.kt | 140 ++++++++++++ .../components/CustomButtonDialogs.kt | 216 ++++++++++++++++++ .../components/CustomButtonListItem.kt | 100 ++++++++ .../ui/custombuttons/CustomButtonsScreen.kt | 76 ++++++ .../CustomButtonsScreenViewModel.kt | 85 +++++++ .../mehiz/mpvkt/ui/player/PlayerViewModel.kt | 27 +++ .../mpvkt/ui/player/controls/PlayerSheets.kt | 6 +- .../controls/components/sheets/MoreSheet.kt | 30 +++ .../mpvkt/ui/preferences/PreferencesScreen.kt | 9 + app/src/main/res/values/strings.xml | 14 ++ gradle/libs.versions.toml | 3 +- 20 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/live/mehiz/mpvkt/database/dao/CustomButtonDao.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/database/entities/CustomButtonEntity.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/database/repository/CustomButtonRepositoryImpl.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/di/ViewModel.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/domain/custombuttons/repository/CustomButtonRepository.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/CustomButtonsScreen.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonDialogs.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonListItem.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreen.kt create mode 100644 app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreenViewModel.kt diff --git a/app/src/main/java/live/mehiz/mpvkt/App.kt b/app/src/main/java/live/mehiz/mpvkt/App.kt index 069820c..6236b32 100644 --- a/app/src/main/java/live/mehiz/mpvkt/App.kt +++ b/app/src/main/java/live/mehiz/mpvkt/App.kt @@ -4,6 +4,7 @@ import android.app.Application import live.mehiz.mpvkt.di.DatabaseModule import live.mehiz.mpvkt.di.FileManagerModule import live.mehiz.mpvkt.di.PreferencesModule +import live.mehiz.mpvkt.di.ViewModelModule import live.mehiz.mpvkt.presentation.crash.CrashActivity import live.mehiz.mpvkt.presentation.crash.GlobalExceptionHandler import org.koin.android.ext.koin.androidContext @@ -19,6 +20,7 @@ class App : Application() { PreferencesModule, DatabaseModule, FileManagerModule, + ViewModelModule, ) } } diff --git a/app/src/main/java/live/mehiz/mpvkt/database/Migrations.kt b/app/src/main/java/live/mehiz/mpvkt/database/Migrations.kt index f2c6e1e..431d848 100644 --- a/app/src/main/java/live/mehiz/mpvkt/database/Migrations.kt +++ b/app/src/main/java/live/mehiz/mpvkt/database/Migrations.kt @@ -6,6 +6,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase val Migrations: Array = arrayOf( MIGRATION1to2, MIGRATION2to3, + MIGRATION3to4, ) private object MIGRATION1to2 : Migration(1, 2) { @@ -22,3 +23,19 @@ private object MIGRATION2to3 : Migration(2, 3) { db.execSQL("ALTER TABLE PlaybackStateEntity ADD COLUMN playbackSpeed REAL NOT NULL DEFAULT 0") } } + +private object MIGRATION3to4 : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `CustomButtonEntity` ( + `id` INTEGER NOT NULL, + `title` TEXT NOT NULL, + `content` TEXT NOT NULL, + `index` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/database/MpvKtDatabase.kt b/app/src/main/java/live/mehiz/mpvkt/database/MpvKtDatabase.kt index e04ecf0..618efcf 100644 --- a/app/src/main/java/live/mehiz/mpvkt/database/MpvKtDatabase.kt +++ b/app/src/main/java/live/mehiz/mpvkt/database/MpvKtDatabase.kt @@ -2,10 +2,13 @@ package live.mehiz.mpvkt.database import androidx.room.Database import androidx.room.RoomDatabase +import live.mehiz.mpvkt.database.dao.CustomButtonDao import live.mehiz.mpvkt.database.dao.PlaybackStateDao +import live.mehiz.mpvkt.database.entities.CustomButtonEntity import live.mehiz.mpvkt.database.entities.PlaybackStateEntity -@Database(entities = [PlaybackStateEntity::class], version = 3) +@Database(entities = [PlaybackStateEntity::class, CustomButtonEntity::class], version = 4) abstract class MpvKtDatabase : RoomDatabase() { abstract fun videoDataDao(): PlaybackStateDao + abstract fun customButtonDao(): CustomButtonDao } diff --git a/app/src/main/java/live/mehiz/mpvkt/database/dao/CustomButtonDao.kt b/app/src/main/java/live/mehiz/mpvkt/database/dao/CustomButtonDao.kt new file mode 100644 index 0000000..812fbb8 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/database/dao/CustomButtonDao.kt @@ -0,0 +1,68 @@ +package live.mehiz.mpvkt.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow +import live.mehiz.mpvkt.database.entities.CustomButtonEntity + +@Dao +interface CustomButtonDao { + @Upsert + suspend fun upsert(customButtonEntity: CustomButtonEntity) + + @Query("SELECT * FROM CustomButtonEntity ORDER BY `index`") + fun getCustomButtons(): Flow> + + @Query("SELECT * FROM CustomButtonEntity WHERE `index` > :currentIndex ORDER BY `index` ASC LIMIT 1") + suspend fun getNextCustomButton(currentIndex: Int): CustomButtonEntity? + + @Query("SELECT * FROM CustomButtonEntity WHERE `index` < :currentIndex ORDER BY `index` DESC LIMIT 1") + suspend fun getPreviousCustomButton(currentIndex: Int): CustomButtonEntity? + + @Transaction + suspend fun increaseIndex(customButton: CustomButtonEntity) { + val nextCustomButton = getNextCustomButton(customButton.index) ?: return + + val current = customButton.copy(index = nextCustomButton.index) + val next = nextCustomButton.copy(index = customButton.index) + + updateCustomButton(current) + updateCustomButton(next) + } + + @Transaction + suspend fun decreaseIndex(customButton: CustomButtonEntity) { + val previousCustomButton = getPreviousCustomButton(customButton.index) ?: return + + val current = customButton.copy(index = previousCustomButton.index) + val previous = previousCustomButton.copy(index = customButton.index) + + updateCustomButton(current) + updateCustomButton(previous) + } + + @Update + suspend fun updateCustomButton(customButton: CustomButtonEntity) + + @Query("SELECT * FROM CustomButtonEntity WHERE `index` > :deletedIndex") + suspend fun getNotesAfterOrder(deletedIndex: Int): List + + @Transaction + suspend fun deleteAndReindex(customButton: CustomButtonEntity) { + val buttonsAfter = getNotesAfterOrder(customButton.index) + + deleteCustomButton(customButton) + + buttonsAfter.forEach { button -> + val updatedButton = button.copy(index = button.index - 1) + updateCustomButton(updatedButton) + } + } + + @Delete + suspend fun deleteCustomButton(customButtonEntity: CustomButtonEntity) +} diff --git a/app/src/main/java/live/mehiz/mpvkt/database/entities/CustomButtonEntity.kt b/app/src/main/java/live/mehiz/mpvkt/database/entities/CustomButtonEntity.kt new file mode 100644 index 0000000..9af8b8c --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/database/entities/CustomButtonEntity.kt @@ -0,0 +1,13 @@ +package live.mehiz.mpvkt.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class CustomButtonEntity( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val title: String, + val content: String, + val index: Int, +) diff --git a/app/src/main/java/live/mehiz/mpvkt/database/repository/CustomButtonRepositoryImpl.kt b/app/src/main/java/live/mehiz/mpvkt/database/repository/CustomButtonRepositoryImpl.kt new file mode 100644 index 0000000..123ce71 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/database/repository/CustomButtonRepositoryImpl.kt @@ -0,0 +1,34 @@ +package live.mehiz.mpvkt.database.repository + +import kotlinx.coroutines.flow.Flow +import live.mehiz.mpvkt.database.MpvKtDatabase +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.domain.custombuttons.repository.CustomButtonRepository + +class CustomButtonRepositoryImpl( + private val database: MpvKtDatabase, +) : CustomButtonRepository { + override fun getCustomButtons(): Flow> { + return database.customButtonDao().getCustomButtons() + } + + override suspend fun upsert(customButtonEntity: CustomButtonEntity) { + database.customButtonDao().upsert(customButtonEntity) + } + + override suspend fun deleteAndReindex(customButtonEntity: CustomButtonEntity) { + database.customButtonDao().deleteAndReindex(customButtonEntity) + } + + override suspend fun increaseIndex(customButtonEntity: CustomButtonEntity) { + database.customButtonDao().increaseIndex(customButtonEntity) + } + + override suspend fun decreaseIndex(customButtonEntity: CustomButtonEntity) { + database.customButtonDao().decreaseIndex(customButtonEntity) + } + + override suspend fun updateButton(customButtonEntity: CustomButtonEntity) { + database.customButtonDao().updateCustomButton(customButtonEntity) + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/di/DatabaseModule.kt b/app/src/main/java/live/mehiz/mpvkt/di/DatabaseModule.kt index e0c6d20..96bf091 100644 --- a/app/src/main/java/live/mehiz/mpvkt/di/DatabaseModule.kt +++ b/app/src/main/java/live/mehiz/mpvkt/di/DatabaseModule.kt @@ -3,7 +3,9 @@ package live.mehiz.mpvkt.di import androidx.room.Room import live.mehiz.mpvkt.database.Migrations import live.mehiz.mpvkt.database.MpvKtDatabase +import live.mehiz.mpvkt.database.repository.CustomButtonRepositoryImpl import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val DatabaseModule = module { @@ -13,4 +15,6 @@ val DatabaseModule = module { .addMigrations(migrations = Migrations) .build() } + + singleOf(::CustomButtonRepositoryImpl) } diff --git a/app/src/main/java/live/mehiz/mpvkt/di/ViewModel.kt b/app/src/main/java/live/mehiz/mpvkt/di/ViewModel.kt new file mode 100644 index 0000000..41da9e7 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/di/ViewModel.kt @@ -0,0 +1,9 @@ +package live.mehiz.mpvkt.di + +import live.mehiz.mpvkt.ui.custombuttons.CustomButtonsScreenViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val ViewModelModule = module { + viewModelOf(::CustomButtonsScreenViewModel) +} diff --git a/app/src/main/java/live/mehiz/mpvkt/domain/custombuttons/repository/CustomButtonRepository.kt b/app/src/main/java/live/mehiz/mpvkt/domain/custombuttons/repository/CustomButtonRepository.kt new file mode 100644 index 0000000..6c958bb --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/domain/custombuttons/repository/CustomButtonRepository.kt @@ -0,0 +1,18 @@ +package live.mehiz.mpvkt.domain.custombuttons.repository + +import kotlinx.coroutines.flow.Flow +import live.mehiz.mpvkt.database.entities.CustomButtonEntity + +interface CustomButtonRepository { + fun getCustomButtons(): Flow> + + suspend fun upsert(customButtonEntity: CustomButtonEntity) + + suspend fun deleteAndReindex(customButtonEntity: CustomButtonEntity) + + suspend fun increaseIndex(customButtonEntity: CustomButtonEntity) + + suspend fun decreaseIndex(customButtonEntity: CustomButtonEntity) + + suspend fun updateButton(customButtonEntity: CustomButtonEntity) +} diff --git a/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/CustomButtonsScreen.kt b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/CustomButtonsScreen.kt new file mode 100644 index 0000000..eb6491c --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/CustomButtonsScreen.kt @@ -0,0 +1,140 @@ +package live.mehiz.mpvkt.presentation.custombuttons + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import live.mehiz.mpvkt.R +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.presentation.custombuttons.components.CustomButtonListItem +import live.mehiz.mpvkt.ui.theme.spacing + +@Suppress("ModifierNotUsedAtRoot", "ModifierMissing") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomButtonsScreen( + buttons: List, + onClickAdd: () -> Unit, + onClickRename: (CustomButtonEntity) -> Unit, + onClickDelete: (CustomButtonEntity) -> Unit, + onClickMoveUp: (CustomButtonEntity) -> Unit, + onClickMoveDown: (CustomButtonEntity) -> Unit, + onNavigateBack: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Scaffold( + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.pref_custom_buttons_title)) + }, + navigationIcon = { + IconButton(onClick = { onNavigateBack() }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, null) + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { onClickAdd() }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text(text = stringResource(id = R.string.pref_custom_button_action_add)) }, + ) + } + ) { padding -> + if (buttons.isEmpty()) { + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(id = R.string.pref_custom_button_empty), + textAlign = TextAlign.Center, + ) + } + return@Scaffold + } + + val layoutDirection = LocalLayoutDirection.current + CustomButtonsContent( + customButtons = buttons, + lazyListState = lazyListState, + paddingValues = PaddingValues( + top = MaterialTheme.spacing.small + padding.calculateTopPadding(), + start = MaterialTheme.spacing.medium + padding.calculateStartPadding(layoutDirection), + end = MaterialTheme.spacing.medium + padding.calculateEndPadding(layoutDirection), + bottom = padding.calculateBottomPadding(), + ), + onClickRename = onClickRename, + onClickDelete = onClickDelete, + onMoveUp = onClickMoveUp, + onMoveDown = onClickMoveDown, + ) + } +} + +@Composable +private fun CustomButtonsContent( + customButtons: List, + lazyListState: LazyListState, + paddingValues: PaddingValues, + onClickRename: (CustomButtonEntity) -> Unit, + onClickDelete: (CustomButtonEntity) -> Unit, + onMoveUp: (CustomButtonEntity) -> Unit, + onMoveDown: (CustomButtonEntity) -> Unit, +) { + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), + ) { + itemsIndexed( + items = customButtons, + key = { _, button -> "button-${button.index}" } + ) { index, button -> + CustomButtonListItem( + modifier = Modifier.animateItem(), + customButton = button, + canMoveUp = index != 0, + canMoveDown = index != customButtons.lastIndex, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + onRename = { onClickRename(button) }, + onDelete = { onClickDelete(button) }, + ) + } + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonDialogs.kt b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonDialogs.kt new file mode 100644 index 0000000..960e6c2 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonDialogs.kt @@ -0,0 +1,216 @@ +package live.mehiz.mpvkt.presentation.custombuttons.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import live.mehiz.mpvkt.R +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.ui.theme.spacing +import kotlin.time.Duration.Companion.seconds + +@Composable +fun CustomButtonAddDialog( + onDismissRequest: () -> Unit, + onAdd: (String, String) -> Unit, + buttonNames: ImmutableList, +) { + var title by remember { mutableStateOf("") } + var content by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val titleAlreadyExists = remember(title) { buttonNames.contains(title) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + enabled = title.isNotEmpty() && content.isNotEmpty() && !titleAlreadyExists, + onClick = { + onAdd(title, content) + onDismissRequest() + } + ) { + Text(text = stringResource(id = R.string.pref_custom_button_action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.generic_cancel)) + } + }, + title = { + Text(text = stringResource(id = R.string.pref_custom_button_add_button)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) + ) { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = title, + onValueChange = { title = it }, + label = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_title_text)) + }, + supportingText = { + val msgRes = if (title.isNotEmpty() && titleAlreadyExists) { + R.string.pref_custom_button_action_add_already_exists + } else { + R.string.pref_custom_button_action_add_required + } + Text(text = stringResource(id = msgRes)) + }, + isError = title.isNotEmpty() && titleAlreadyExists, + singleLine = true, + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_content_text)) + }, + supportingText = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_required)) + }, + minLines = 3, + ) + } + } + ) + + @Suppress("ForbiddenComment") + LaunchedEffect(focusRequester) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(0.1.seconds) + focusRequester.requestFocus() + } +} + +@Composable +fun CustomButtonDeleteDialog( + onDismissRequest: () -> Unit, + onDelete: () -> Unit, + title: String, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onDelete() + onDismissRequest() + }) { + Text(text = stringResource(R.string.generic_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.generic_cancel)) + } + }, + title = { + Text(text = stringResource(R.string.pref_custom_button_delete_button)) + }, + text = { + Text(text = stringResource(R.string.pref_custom_button_delete_confirmation, title)) + }, + ) +} + +@Composable +fun CustomButtonEditDialog( + onDismissRequest: () -> Unit, + onEdit: (String, String) -> Unit, + buttonNames: ImmutableList, + initialState: CustomButtonEntity, +) { + var title by remember { mutableStateOf(initialState.title) } + var content by remember { mutableStateOf(initialState.content) } + + val focusRequester = remember { FocusRequester() } + val titleAlreadyExists = remember(title) { buttonNames.contains(title) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + enabled = title.isNotEmpty() && content.isNotEmpty() && !titleAlreadyExists, + onClick = { + onEdit(title, content) + onDismissRequest() + } + ) { + Text(text = stringResource(id = R.string.pref_custom_button_action_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = R.string.generic_cancel)) + } + }, + title = { + Text(text = stringResource(id = R.string.pref_custom_button_edit_button)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) + ) { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = title, + onValueChange = { title = it }, + label = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_title_text)) + }, + supportingText = { + val msgRes = if (title.isNotEmpty() && titleAlreadyExists) { + R.string.pref_custom_button_action_add_already_exists + } else { + R.string.pref_custom_button_action_add_required + } + Text(text = stringResource(id = msgRes)) + }, + isError = title.isNotEmpty() && titleAlreadyExists, + singleLine = true, + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_content_text)) + }, + supportingText = { + Text(text = stringResource(id = R.string.pref_custom_button_action_add_required)) + }, + minLines = 3, + ) + } + } + ) + + @Suppress("ForbiddenComment") + LaunchedEffect(focusRequester) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(0.1.seconds) + focusRequester.requestFocus() + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonListItem.kt b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonListItem.kt new file mode 100644 index 0000000..be49bf7 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/presentation/custombuttons/components/CustomButtonListItem.kt @@ -0,0 +1,100 @@ +package live.mehiz.mpvkt.presentation.custombuttons.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.ArrowDropUp +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import live.mehiz.mpvkt.R +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.ui.theme.spacing + +@Composable +fun CustomButtonListItem( + customButton: CustomButtonEntity, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: (CustomButtonEntity) -> Unit, + onMoveDown: (CustomButtonEntity) -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onRename() } + .padding( + start = MaterialTheme.spacing.medium, + top = MaterialTheme.spacing.medium, + end = MaterialTheme.spacing.medium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.Outlined.Code, contentDescription = null) + Text( + text = customButton.title, + modifier = Modifier + .padding(start = MaterialTheme.spacing.medium), + ) + } + Text( + text = customButton.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding( + top = MaterialTheme.spacing.smaller, + start = MaterialTheme.spacing.medium + ), + ) + Row { + IconButton( + onClick = { onMoveUp(customButton) }, + enabled = canMoveUp, + ) { + Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null) + } + IconButton( + onClick = { onMoveDown(customButton) }, + enabled = canMoveDown, + ) { + Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onRename) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(id = R.string.pref_custom_button_edit_button), + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(id = R.string.pref_custom_button_delete_button), + ) + } + } + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreen.kt b/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreen.kt new file mode 100644 index 0000000..e1477eb --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreen.kt @@ -0,0 +1,76 @@ +package live.mehiz.mpvkt.ui.custombuttons + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import kotlinx.collections.immutable.toImmutableList +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.presentation.Screen +import live.mehiz.mpvkt.presentation.custombuttons.CustomButtonsScreen +import live.mehiz.mpvkt.presentation.custombuttons.components.CustomButtonAddDialog +import live.mehiz.mpvkt.presentation.custombuttons.components.CustomButtonDeleteDialog +import live.mehiz.mpvkt.presentation.custombuttons.components.CustomButtonEditDialog +import org.koin.compose.viewmodel.koinViewModel + +object CustomButtonsScreen : Screen() { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val viewModel = koinViewModel() + val customButtons by viewModel.customButtons.collectAsState() + val dialog by viewModel.dialog.collectAsState() + + CustomButtonsScreen( + buttons = customButtons, + onClickAdd = { viewModel.showDialog(CustomButtonDialog.Create) }, + onClickRename = { viewModel.showDialog(CustomButtonDialog.Edit(it)) }, + onClickDelete = { viewModel.showDialog(CustomButtonDialog.Delete(it)) }, + onClickMoveUp = viewModel::moveUp, + onClickMoveDown = viewModel::moveDown, + onNavigateBack = navigator::pop, + ) + + when (dialog) { + is CustomButtonDialog.None -> {} + is CustomButtonDialog.Create -> { + CustomButtonAddDialog( + onDismissRequest = viewModel::dismissDialog, + onAdd = viewModel::addCustomButton, + buttonNames = customButtons.map { it.title }.toImmutableList(), + ) + } + is CustomButtonDialog.Edit -> { + val button = (dialog as CustomButtonDialog.Edit).customButton + CustomButtonEditDialog( + onDismissRequest = viewModel::dismissDialog, + onEdit = { title, content -> + viewModel.editButton( + button.copy(title = title, content = content) + ) + }, + buttonNames = customButtons.filter { it.title != button.title } + .map { it.title } + .toImmutableList(), + initialState = button, + ) + } + is CustomButtonDialog.Delete -> { + val button = (dialog as CustomButtonDialog.Delete).customButton + CustomButtonDeleteDialog( + onDismissRequest = viewModel::dismissDialog, + onDelete = { viewModel.removeButton(button) }, + title = button.title, + ) + } + } + } +} + +sealed interface CustomButtonDialog { + data object None : CustomButtonDialog + data object Create : CustomButtonDialog + data class Edit(val customButton: CustomButtonEntity) : CustomButtonDialog + data class Delete(val customButton: CustomButtonEntity) : CustomButtonDialog +} diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreenViewModel.kt b/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreenViewModel.kt new file mode 100644 index 0000000..02f8177 --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/ui/custombuttons/CustomButtonsScreenViewModel.kt @@ -0,0 +1,85 @@ +package live.mehiz.mpvkt.ui.custombuttons + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import live.mehiz.mpvkt.database.entities.CustomButtonEntity +import live.mehiz.mpvkt.database.repository.CustomButtonRepositoryImpl + +class CustomButtonsScreenViewModel( + private val customButtonsRepository: CustomButtonRepositoryImpl, +) : ViewModel() { + private val _dialog = MutableStateFlow(CustomButtonDialog.None) + val dialog = _dialog.asStateFlow() + + val customButtons: StateFlow> = customButtonsRepository.getCustomButtons() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + fun addCustomButton(title: String, content: String) { + viewModelScope.launch(Dispatchers.IO) { + customButtonsRepository.upsert( + CustomButtonEntity( + title = title, + content = content, + index = customButtons.value.size, + ) + ) + } + } + + fun moveUp(customButton: CustomButtonEntity) { + viewModelScope.launch(Dispatchers.IO) { + customButtonsRepository.decreaseIndex(customButton) + } + } + + fun moveDown(customButton: CustomButtonEntity) { + viewModelScope.launch(Dispatchers.IO) { + customButtonsRepository.increaseIndex(customButton) + } + } + + fun editButton(customButton: CustomButtonEntity) { + viewModelScope.launch(Dispatchers.IO) { + customButtonsRepository.upsert(customButton) + } + } + + fun removeButton(customButton: CustomButtonEntity) { + viewModelScope.launch(Dispatchers.IO) { + customButtonsRepository.deleteAndReindex(customButton) + } + } + + fun showDialog(dialog: CustomButtonDialog) { + _dialog.update { _ -> dialog } + } + + fun dismissDialog() { + _dialog.update { _ -> CustomButtonDialog.None } + } +} + +sealed interface CustomButtonsUiState { + data object Loading : CustomButtonsUiState + data class Success(val buttons: List) : CustomButtonsUiState + data class Error(val message: String) : CustomButtonsUiState +} + +fun CustomButtonsUiState.getButtons(): List { + return when (this) { + is CustomButtonsUiState.Success -> this.buttons + else -> emptyList() + } +} diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt index 7c25b1c..1e906c4 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt @@ -13,15 +13,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.vivvvek.seeker.Segment import `is`.xyz.mpv.MPVLib +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import live.mehiz.mpvkt.R +import live.mehiz.mpvkt.database.MpvKtDatabase import live.mehiz.mpvkt.preferences.GesturePreferences import live.mehiz.mpvkt.preferences.PlayerPreferences +import live.mehiz.mpvkt.ui.custombuttons.CustomButtonsUiState import org.koin.java.KoinJavaComponent.inject @Suppress("TooManyFunctions") @@ -30,6 +35,28 @@ class PlayerViewModel( ) : ViewModel() { private val playerPreferences: PlayerPreferences by inject(PlayerPreferences::class.java) private val gesturePreferences: GesturePreferences by inject(GesturePreferences::class.java) + private val mpvKtDatabase: MpvKtDatabase by inject(MpvKtDatabase::class.java) + + private val _customButtons = MutableStateFlow(CustomButtonsUiState.Loading) + val customButtons = _customButtons.asStateFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + mpvKtDatabase.customButtonDao().getCustomButtons() + .catch { e -> + Log.e(TAG, e.message ?: "Unable to fetch buttons") + _customButtons.update { _ -> CustomButtonsUiState.Error(e.message ?: "Unable to fetch buttons") } + } + .collectLatest { buttons -> + _customButtons.update { _ -> CustomButtonsUiState.Success(buttons) } + } + } catch (e: Exception) { + Log.e(TAG, e.message ?: "Unable to fetch buttons") + _customButtons.update { _ -> CustomButtonsUiState.Error(e.message ?: "Unable to fetch buttons") } + } + } + } private val _currentDecoder = MutableStateFlow(getDecoderFromValue(MPVLib.getPropertyString("hwdec"))) val currentDecoder = _currentDecoder.asStateFlow() diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/PlayerSheets.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/PlayerSheets.kt index c5dbc7e..8cbe706 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/PlayerSheets.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/PlayerSheets.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.update +import live.mehiz.mpvkt.ui.custombuttons.getButtons import live.mehiz.mpvkt.ui.player.Panels import live.mehiz.mpvkt.ui.player.PlayerViewModel import live.mehiz.mpvkt.ui.player.Sheets @@ -26,6 +27,8 @@ fun PlayerSheets() { val audioTracks by viewModel.audioTracks.collectAsState() val selectedAudio by viewModel.selectedAudio.collectAsState() val sheetShown by viewModel.sheetShown.collectAsState() + val buttons by viewModel.customButtons.collectAsState() + val onDismissRequest: () -> Unit = { viewModel.sheetShown.update { Sheets.None } viewModel.showControls() @@ -106,7 +109,8 @@ fun PlayerSheets() { onEnterFiltersPanel = { viewModel.panelShown.update { Panels.VideoFilters } onDismissRequest() - } + }, + customButtons = buttons.getButtons(), ) } diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/components/sheets/MoreSheet.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/components/sheets/MoreSheet.kt index 61576f8..e137f82 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/components/sheets/MoreSheet.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/components/sheets/MoreSheet.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -47,6 +49,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import `is`.xyz.mpv.MPVLib import live.mehiz.mpvkt.R +import live.mehiz.mpvkt.database.entities.CustomButtonEntity import live.mehiz.mpvkt.preferences.AdvancedPreferences import live.mehiz.mpvkt.preferences.AudioChannels import live.mehiz.mpvkt.preferences.AudioPreferences @@ -55,17 +58,21 @@ import live.mehiz.mpvkt.presentation.components.PlayerSheet import live.mehiz.mpvkt.ui.player.PlayerViewModel import live.mehiz.mpvkt.ui.theme.spacing import org.koin.compose.koinInject +import java.io.File +@OptIn(ExperimentalLayoutApi::class) @Composable fun MoreSheet( onDismissRequest: () -> Unit, onEnterFiltersPanel: () -> Unit, + customButtons: List, modifier: Modifier = Modifier, ) { val viewModel = koinInject() val advancedPreferences = koinInject() val audioPreferences = koinInject() val statisticsPage by advancedPreferences.enabledStatisticsPage.collectAsState() + PlayerSheet( onDismissRequest, modifier, @@ -142,6 +149,29 @@ fun MoreSheet( ) } } + if (customButtons.isNotEmpty()) { + Text(text = stringResource(id = R.string.player_sheets_custom_buttons_title)) + FlowRow( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.smaller), + maxItemsInEachRow = Int.MAX_VALUE, + ) { + customButtons.forEach { button -> + FilterChip( + onClick = { + val tempFile = File.createTempFile("script", ".lua").apply { + writeText(button.content) + deleteOnExit() + } + + MPVLib.command(arrayOf("load-script", tempFile.absolutePath)) + }, + label = { Text(text = button.title) }, + selected = false, + ) + } + } + } Text(text = stringResource(id = R.string.pref_audio_channels)) val audioChannels by audioPreferences.audioChannels.collectAsState() LazyRow( diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PreferencesScreen.kt b/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PreferencesScreen.kt index 3f8370b..b6c2a0f 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PreferencesScreen.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PreferencesScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Subtitles +import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -26,6 +27,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import live.mehiz.mpvkt.R import live.mehiz.mpvkt.presentation.Screen +import live.mehiz.mpvkt.ui.custombuttons.CustomButtonsScreen import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.preference @@ -101,6 +103,13 @@ object PreferencesScreen : Screen() { icon = { Icon(Icons.Outlined.Code, null) }, onClick = { navigator.push(AdvancedPreferencesScreen) } ) + preference( + key = "customButtons", + title = { Text(text = stringResource(id = R.string.pref_custom_buttons_title)) }, + summary = { Text(text = stringResource(id = R.string.pref_custom_buttons_summary)) }, + icon = { Icon(Icons.Outlined.Terminal, null) }, + onClick = { navigator.push(CustomButtonsScreen) }, + ) preference( key = "about", title = { Text(text = stringResource(id = R.string.pref_about_title)) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22c1729..7213f0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,19 @@ Clear cached mpv configurations Cleared cached mpv configurations + Custom buttons + Execute lua code with custom buttons + Add + Title + Button already exists! + *required + Lua code + Add custom button + You have no custom buttons. Tap the plus button to add custom buttons. + Edit button + Delete button + Do you wish to delete the button \"%s\"? + About Acknowledgments, licenses App version @@ -191,6 +204,7 @@ More Default statistics page Page %d + Custom buttons %d seconds %.2fx %c%s\n[%s] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 413d4da..a7bd717 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ seeker = { module = "io.github.2307vivek:seeker", version = "1.2.2" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } @@ -61,7 +62,7 @@ about-libs-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = " about-libs-ui-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "about-libs" } simple-icons = { module = "br.com.devsrsouza.compose.icons:simple-icons", version = "1.1.1" } [bundles] -koin = ["koin-core", "koin-android", "koin-compose"] +koin = ["koin-core", "koin-android", "koin-compose", "koin-viewmodel"] voyager = ["voyager-navigator", "voyager-transitions"] about-libs = ["about-libs-core", "about-libs-ui-m3"]