diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 503ac7a21a..e17dd32bee 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -612,7 +612,8 @@ class MainActivity : ComponentActivity() { persistentListOf( BottomNavItem("Home", R.string.nav_bar_home, R.drawable.rounded_home_24, R.drawable.home_24_rounded_filled, Screen.Home), BottomNavItem("Search", R.string.nav_bar_search, R.drawable.rounded_search_24, R.drawable.rounded_search_24, Screen.Search), - BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library) + BottomNavItem("Library", R.string.nav_bar_library, R.drawable.rounded_library_music_24, R.drawable.round_library_music_24, Screen.Library), + BottomNavItem("Smart", R.string.nav_bar_smart, R.drawable.rounded_all_inclusive_24, R.drawable.rounded_all_inclusive_24, Screen.NlpCommand) ) } val navBackStackEntry by navController.currentBackStackEntryAsState() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandIntent.kt b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandIntent.kt new file mode 100644 index 0000000000..a6982dc3c2 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandIntent.kt @@ -0,0 +1,18 @@ +package com.theveloper.pixelplay.data.nlp + +sealed class NlpCommandIntent { + data class CreatePlaylist( + val playlistName: String, + val targetQueries: List + ) : NlpCommandIntent() + + data class DeleteArtist( + val targetQueries: List + ) : NlpCommandIntent() + + data class CategorizeGenre( + val genreNames: List + ) : NlpCommandIntent() + + object Unknown : NlpCommandIntent() +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandParser.kt b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandParser.kt new file mode 100644 index 0000000000..9ce6a2da7d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandParser.kt @@ -0,0 +1,86 @@ +package com.theveloper.pixelplay.data.nlp + +object NlpCommandParser { + + private val CREATE_PLAYLIST_REGEX = Regex( + pattern = """(?:create|make|build|generate)\s+(?:a\s+)?playlist\s+(?:of|for|from|with|named|called)?\s*(.+)""", + option = RegexOption.IGNORE_CASE + ) + + private val DELETE_ARTIST_REGEX = Regex( + pattern = """(?:delete|remove|erase)\s+(?:artist|singer|band|musician)?\s*(.+)""", + option = RegexOption.IGNORE_CASE + ) + + private val CATEGORIZE_GENRE_REGEX = Regex( + pattern = """(?:categorize|group|organize|sort)\s+(?:songs?|music|tracks?)?\s*(?:by)?\s*(?:genre)?\s+(.+)""", + option = RegexOption.IGNORE_CASE + ) + + private val CLEAN_SUFFIX_REGEX = Regex( + pattern = """(?:'s)?\s+(?:songs?|tracks?|music|mix|albums?)$""", + option = RegexOption.IGNORE_CASE + ) + + private val CLEAN_PREFIX_REGEX = Regex( + pattern = """^(?:songs?|tracks?|music|mix|albums?)\s+(?:by|of|from|with)\s+""", + option = RegexOption.IGNORE_CASE + ) + + fun parse(rawInput: String): NlpCommandIntent { + val cleanedInput = rawInput.trim() + if (cleanedInput.isBlank()) return NlpCommandIntent.Unknown + + CREATE_PLAYLIST_REGEX.find(cleanedInput)?.let { match -> + val rawTarget = match.groupValues[1].trim() + val targets = cleanAndSplit(rawTarget) + if (targets.isNotEmpty()) { + return NlpCommandIntent.CreatePlaylist( + playlistName = buildPlaylistName(targets), + targetQueries = targets + ) + } + } + + DELETE_ARTIST_REGEX.find(cleanedInput)?.let { match -> + val rawTarget = match.groupValues[1].trim() + val targets = cleanAndSplit(rawTarget) + if (targets.isNotEmpty()) { + return NlpCommandIntent.DeleteArtist(targetQueries = targets) + } + } + + CATEGORIZE_GENRE_REGEX.find(cleanedInput)?.let { match -> + val rawGenre = match.groupValues[1].trim() + val genres = cleanAndSplit(rawGenre) + if (genres.isNotEmpty()) { + return NlpCommandIntent.CategorizeGenre(genreNames = genres) + } + } + + return NlpCommandIntent.Unknown + } + + private fun cleanAndSplit(rawTarget: String): List { + val parts = rawTarget.split(Regex("""\s+and\s+|\s+or\s+|,\s*""")) + return parts.map { part -> + var cleaned = part.trim() + cleaned = CLEAN_PREFIX_REGEX.replace(cleaned, "") + cleaned = CLEAN_SUFFIX_REGEX.replace(cleaned, "") + cleaned.trim() + }.filter { it.isNotBlank() } + } + + private fun buildPlaylistName(targets: List): String { + val capitalizedTargets = targets.map { target -> + target.split(" ").joinToString(" ") { word -> + word.replaceFirstChar { it.uppercaseChar() } + } + } + return when (capitalizedTargets.size) { + 1 -> "${capitalizedTargets.first()} — Mix" + 2 -> "${capitalizedTargets[0]} & ${capitalizedTargets[1]}" + else -> "${capitalizedTargets.take(2).joinToString(", ")} & More" + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandRepository.kt new file mode 100644 index 0000000000..5c9feec739 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandRepository.kt @@ -0,0 +1,231 @@ +package com.theveloper.pixelplay.data.nlp + +import android.content.Context +import android.util.Log +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository +import com.theveloper.pixelplay.data.repository.MusicRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NlpCommandRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val musicDao: MusicDao, + private val playlistPreferencesRepository: PlaylistPreferencesRepository +) { + + companion object { + private const val TAG = "NlpCommandRepository" + } + + suspend fun execute(intent: NlpCommandIntent): NlpCommandResult = withContext(Dispatchers.IO) { + when (intent) { + is NlpCommandIntent.CreatePlaylist -> createPlaylistByTarget(intent) + is NlpCommandIntent.DeleteArtist -> resolveDeleteArtist(intent) + is NlpCommandIntent.CategorizeGenre -> categorizeByGenre(intent) + is NlpCommandIntent.Unknown -> NlpCommandResult.Error( + "Sorry, I didn't understand that command. Try:\n" + + "• \"create playlist of [artist]\"\n" + + "• \"delete artist [name]\"\n" + + "• \"categorize songs by genre [genre]\"" + ) + } + } + + private suspend fun createPlaylistByTarget(intent: NlpCommandIntent.CreatePlaylist): NlpCommandResult { + val allSongs = musicRepository.getAllSongsOnce() + if (allSongs.isEmpty()) { + return NlpCommandResult.Error("Your library is empty. Add some songs first.") + } + + val allArtistNames = allSongs.map { it.artist }.distinct() + val allGenres = allSongs.mapNotNull { it.genre }.distinct() + + val matchingSongs = mutableListOf() + val resolvedTargets = mutableListOf() + + for (query in intent.targetQueries) { + val matchedArtist = NlpFuzzyMatcher.findBestMatch(query, allArtistNames) + if (matchedArtist != null) { + val songs = allSongs.filter { it.artist.equals(matchedArtist, ignoreCase = true) } + matchingSongs.addAll(songs) + resolvedTargets.add(matchedArtist) + } else { + val matchedGenre = NlpFuzzyMatcher.findBestMatch(query, allGenres) + if (matchedGenre != null) { + val songs = allSongs.filter { it.genre.equals(matchedGenre, ignoreCase = true) } + matchingSongs.addAll(songs) + resolvedTargets.add(matchedGenre) + } + } + } + + if (matchingSongs.isEmpty()) { + return NlpCommandResult.Error( + "No artists or genres found matching: ${intent.targetQueries.joinToString(", ")}. " + + "Check the spelling." + ) + } + + val uniqueSongs = matchingSongs.distinctBy { it.id } + val songIds = uniqueSongs.map { it.id } + + playlistPreferencesRepository.createPlaylist( + name = intent.playlistName, + songIds = songIds + ) + + return NlpCommandResult.Success( + "✓ Playlist \"${intent.playlistName}\" created with ${uniqueSongs.size} songs " + + "(matched: ${resolvedTargets.joinToString(", ")})." + ) + } + + private suspend fun resolveDeleteArtist(intent: NlpCommandIntent.DeleteArtist): NlpCommandResult { + val allSongs = musicRepository.getAllSongsOnce() + val allArtistNames = allSongs.map { it.artist }.distinct() + + val matchingSongs = mutableListOf() + val resolvedArtists = mutableListOf() + + for (query in intent.targetQueries) { + val matchedArtist = NlpFuzzyMatcher.findBestMatch(query, allArtistNames) + if (matchedArtist != null) { + val songs = allSongs.filter { it.artist.equals(matchedArtist, ignoreCase = true) } + matchingSongs.addAll(songs) + resolvedArtists.add(matchedArtist) + } + } + + if (matchingSongs.isEmpty()) { + return NlpCommandResult.Error( + "No artists found matching: ${intent.targetQueries.joinToString(", ")}. " + + "Check the spelling." + ) + } + + val uniqueSongs = matchingSongs.distinctBy { it.id } + + // Only delete local songs (filter out cloud providers) + val localSongPaths = uniqueSongs + .filter { it.path.isNotBlank() && !it.contentUriString.startsWith("telegram://") + && !it.contentUriString.startsWith("netease://") + && !it.contentUriString.startsWith("gdrive://") + && !it.contentUriString.startsWith("qqmusic://") + && !it.contentUriString.startsWith("navidrome://") + && !it.contentUriString.startsWith("jellyfin://") } + .map { it.path } + + val artistListStr = resolvedArtists.joinToString(" & ") + return NlpCommandResult.PendingConfirmation( + message = "⚠️ This will permanently delete ${uniqueSongs.size} song(s) by " + + "\"$artistListStr\" from your device. This action cannot be undone.", + songFilePaths = localSongPaths, + confirmedIntent = intent.copy(targetQueries = resolvedArtists) + ) + } + + suspend fun executeDeleteArtist( + songFilePaths: List, + artistName: String + ): NlpCommandResult = withContext(Dispatchers.IO) { + var deletedFiles = 0 + var failedFiles = 0 + + for (path in songFilePaths) { + if (path.isBlank()) continue + try { + val file = File(path) + if (file.exists()) { + if (file.delete()) { + deletedFiles++ + Log.d(TAG, "Deleted file: $path") + } else { + failedFiles++ + Log.w(TAG, "Could not delete file: $path") + } + } + } catch (e: Exception) { + failedFiles++ + Log.e(TAG, "Error deleting file: $path", e) + } + } + + val resolvedArtists = artistName.split(" & ") + + try { + val allSongs = musicRepository.getAllSongsOnce() + val artistSongIds = allSongs + .filter { song -> resolvedArtists.any { it.equals(song.artist, ignoreCase = true) } } + .map { it.id.toLongOrNull() } + .filterNotNull() + + if (artistSongIds.isNotEmpty()) { + musicDao.deleteSongsAndRelatedData(artistSongIds) + } + } catch (e: Exception) { + Log.e(TAG, "Error removing DB rows for artists: $artistName", e) + } + + val message = buildString { + append("✓ Deleted $deletedFiles file(s) for \"$artistName\".") + if (failedFiles > 0) { + append("\n⚠️ $failedFiles file(s) could not be removed (check app permissions).") + } + } + + NlpCommandResult.Success(message) + } + + private suspend fun categorizeByGenre(intent: NlpCommandIntent.CategorizeGenre): NlpCommandResult { + val allSongs = musicRepository.getAllSongsOnce() + val allGenres = allSongs.mapNotNull { it.genre }.distinct() + if (allGenres.isEmpty()) { + return NlpCommandResult.Error( + "No genre tags found in your library. Add genre metadata to your music files first." + ) + } + + val matchingSongs = mutableListOf() + val resolvedGenres = mutableListOf() + + for (query in intent.genreNames) { + val matchedGenres = NlpFuzzyMatcher.findAllMatches(query, allGenres) + if (matchedGenres.isNotEmpty()) { + val primaryGenre = matchedGenres.first() + val songs = allSongs.filter { song -> + matchedGenres.any { genre -> song.genre.equals(genre, ignoreCase = true) } + } + matchingSongs.addAll(songs) + resolvedGenres.add(primaryGenre) + } + } + + if (matchingSongs.isEmpty()) { + return NlpCommandResult.Error( + "No genres found matching: ${intent.genreNames.joinToString(", ")}. " + + "Check the spelling." + ) + } + + val uniqueSongs = matchingSongs.distinctBy { it.id } + val playlistName = resolvedGenres.joinToString(" & ") + " Mix" + + playlistPreferencesRepository.createPlaylist( + name = playlistName, + songIds = uniqueSongs.map { it.id } + ) + + return NlpCommandResult.Success( + "✓ Playlist \"$playlistName\" created with ${uniqueSongs.size} songs " + + "(matched genres: ${resolvedGenres.joinToString(", ")})." + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandResult.kt b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandResult.kt new file mode 100644 index 0000000000..68cb0ba495 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpCommandResult.kt @@ -0,0 +1,13 @@ +package com.theveloper.pixelplay.data.nlp + +sealed class NlpCommandResult { + data class Success(val message: String) : NlpCommandResult() + + data class PendingConfirmation( + val message: String, + val songFilePaths: List, + val confirmedIntent: NlpCommandIntent + ) : NlpCommandResult() + + data class Error(val message: String) : NlpCommandResult() +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpFuzzyMatcher.kt b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpFuzzyMatcher.kt new file mode 100644 index 0000000000..277315d874 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/nlp/NlpFuzzyMatcher.kt @@ -0,0 +1,77 @@ +package com.theveloper.pixelplay.data.nlp + +object NlpFuzzyMatcher { + + private const val MATCH_THRESHOLD_FRACTION = 0.40 + + fun findBestMatch(query: String, candidates: List): String? { + if (query.isBlank() || candidates.isEmpty()) return null + + val normalizedQuery = query.trim().lowercase() + + val exactMatch = candidates.firstOrNull { it.lowercase() == normalizedQuery } + if (exactMatch != null) return exactMatch + + val startsWithMatch = candidates.firstOrNull { it.lowercase().startsWith(normalizedQuery) } + if (startsWithMatch != null) return startsWithMatch + + var bestCandidate: String? = null + var bestDistance = Int.MAX_VALUE + + for (candidate in candidates) { + val normalizedCandidate = candidate.lowercase() + val dist = levenshteinDistance(normalizedQuery, normalizedCandidate) + val threshold = (maxOf(normalizedQuery.length, normalizedCandidate.length) * MATCH_THRESHOLD_FRACTION).toInt() + + if (dist <= threshold && dist < bestDistance) { + bestDistance = dist + bestCandidate = candidate + } + } + + return bestCandidate + } + + fun findAllMatches(query: String, candidates: List): List { + if (query.isBlank() || candidates.isEmpty()) return emptyList() + + val normalizedQuery = query.trim().lowercase() + + return candidates + .filter { candidate -> + val normalized = candidate.lowercase() + val dist = levenshteinDistance(normalizedQuery, normalized) + val threshold = (maxOf(normalizedQuery.length, normalized.length) * MATCH_THRESHOLD_FRACTION).toInt() + dist <= threshold + } + .sortedBy { candidate -> + levenshteinDistance(normalizedQuery, candidate.lowercase()) + } + } + + fun levenshteinDistance(a: String, b: String): Int { + if (a == b) return 0 + if (a.isEmpty()) return b.length + if (b.isEmpty()) return a.length + + val (shorter, longer) = if (a.length <= b.length) a to b else b to a + + var previousRow = IntArray(shorter.length + 1) { it } + + for (i in longer.indices) { + val currentRow = IntArray(shorter.length + 1) + currentRow[0] = i + 1 + + for (j in shorter.indices) { + val insertCost = previousRow[j + 1] + 1 + val deleteCost = currentRow[j] + 1 + val replaceCost = previousRow[j] + if (shorter[j] == longer[i]) 0 else 1 + currentRow[j + 1] = minOf(insertCost, deleteCost, replaceCost) + } + + previousRow = currentRow + } + + return previousRow[shorter.length] + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt index 6a99712be1..4af104b459 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt @@ -60,6 +60,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.presentation.viewmodel.PlaylistViewModel import kotlinx.coroutines.flow.first import com.theveloper.pixelplay.presentation.components.ScreenWrapper +import com.theveloper.pixelplay.presentation.nlp.NlpCommandScreen @OptIn(UnstableApi::class) @SuppressLint("UnrememberedGetBackStackEntry") @@ -494,6 +495,13 @@ fun AppNavigation( ) } } + composable( + Screen.NlpCommand.route, + ) { + ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) { + NlpCommandScreen() + } + } } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt index 08a7cff5d1..60145abc42 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt @@ -55,4 +55,7 @@ sealed class Screen(val route: String) { object NavidromeDashboard : Screen("navidrome_dashboard") object JellyfinDashboard : Screen("jellyfin_dashboard") + /** Offline local NLP command parser screen. */ + object NlpCommand : Screen("nlp_command") + } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandScreen.kt new file mode 100644 index 0000000000..65d6797af4 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandScreen.kt @@ -0,0 +1,378 @@ +package com.theveloper.pixelplay.presentation.nlp + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontFamily +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.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NlpCommandScreen( + viewModel: NlpCommandViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var commandText by rememberSaveable { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + + val pendingState = uiState as? NlpUiState.PendingConfirmation + var showDeleteConfirmDialog by remember(pendingState) { + mutableStateOf(pendingState != null) + } + + if (showDeleteConfirmDialog && pendingState != null) { + DeletionConfirmationDialog( + state = pendingState, + onConfirm = { + showDeleteConfirmDialog = false + viewModel.confirmDeletion( + filePaths = pendingState.filePaths, + intent = pendingState.intent + ) + }, + onDismiss = { + showDeleteConfirmDialog = false + viewModel.reset() + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Column { + Text( + text = "Smart Commands", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Offline • No AI", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + Spacer(modifier = Modifier.height(8.dp)) + + ExamplesCard() + + OutlinedTextField( + value = commandText, + onValueChange = { + commandText = it + if (uiState !is NlpUiState.Idle) viewModel.reset() + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Type a command…") }, + placeholder = { Text("e.g. create playlist of Nirvana") }, + trailingIcon = { + if (commandText.isNotEmpty()) { + IconButton(onClick = { + commandText = "" + viewModel.reset() + }) { + Icon(Icons.Filled.Clear, contentDescription = "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { + keyboardController?.hide() + viewModel.submitCommand(commandText) + } + ), + maxLines = 3, + shape = RoundedCornerShape(12.dp) + ) + + Button( + onClick = { + keyboardController?.hide() + viewModel.submitCommand(commandText) + }, + enabled = commandText.isNotBlank() && uiState !is NlpUiState.Loading, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(vertical = 14.dp) + ) { + if (uiState is NlpUiState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + imageVector = Icons.Filled.Send, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = if (uiState is NlpUiState.Loading) "Processing…" else "Run Command", + fontWeight = FontWeight.SemiBold + ) + } + + AnimatedVisibility( + visible = uiState is NlpUiState.Success || uiState is NlpUiState.Error, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + ResultCard(state = uiState) + } + } + } +} + +@Composable +private fun ExamplesCard() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Example commands", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + val examples = listOf( + "create playlist of Nirvana", + "create a playlist for Jazz", + "delete artist Linkin Park", + "categorize songs by genre rock", + "group music by genre pop" + ) + examples.forEach { example -> + Text( + text = "• $example", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + } + } +} + +@Composable +private fun ResultCard(state: NlpUiState) { + val isSuccess = state is NlpUiState.Success + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = if (isSuccess) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) + else + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = if (isSuccess) Icons.Filled.Check else Icons.Filled.Clear, + contentDescription = null, + tint = if (isSuccess) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .size(20.dp) + .padding(top = 2.dp) + ) + Text( + text = when (state) { + is NlpUiState.Success -> state.message + is NlpUiState.Error -> state.message + else -> "" + }, + style = MaterialTheme.typography.bodyMedium, + color = if (isSuccess) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun DeletionConfirmationDialog( + state: NlpUiState.PendingConfirmation, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = "Confirm Deletion", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column { + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium + ) + if (state.filePaths.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Files to be deleted:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(6.dp)) + state.filePaths.take(5).forEach { path -> + Text( + text = "• ${path.substringAfterLast('/')}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + if (state.filePaths.size > 5) { + Text( + text = "… and ${state.filePaths.size - 5} more files", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("Delete Permanently", fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandViewModel.kt new file mode 100644 index 0000000000..c95fbe4ab8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/nlp/NlpCommandViewModel.kt @@ -0,0 +1,79 @@ +package com.theveloper.pixelplay.presentation.nlp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.nlp.NlpCommandIntent +import com.theveloper.pixelplay.data.nlp.NlpCommandParser +import com.theveloper.pixelplay.data.nlp.NlpCommandRepository +import com.theveloper.pixelplay.data.nlp.NlpCommandResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed class NlpUiState { + object Idle : NlpUiState() + object Loading : NlpUiState() + data class Success(val message: String) : NlpUiState() + data class PendingConfirmation( + val message: String, + val filePaths: List, + val intent: NlpCommandIntent + ) : NlpUiState() + data class Error(val message: String) : NlpUiState() +} + +@HiltViewModel +class NlpCommandViewModel @Inject constructor( + private val nlpCommandRepository: NlpCommandRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(NlpUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun submitCommand(commandText: String) { + if (commandText.isBlank()) { + _uiState.update { NlpUiState.Error("Please type a command first.") } + return + } + + _uiState.update { NlpUiState.Loading } + + viewModelScope.launch { + val intent = NlpCommandParser.parse(commandText) + val result = nlpCommandRepository.execute(intent) + _uiState.update { result.toUiState() } + } + } + + fun confirmDeletion(filePaths: List, intent: NlpCommandIntent) { + if (intent !is NlpCommandIntent.DeleteArtist) return + + _uiState.update { NlpUiState.Loading } + + viewModelScope.launch { + val result = nlpCommandRepository.executeDeleteArtist( + songFilePaths = filePaths, + artistName = intent.targetQueries.joinToString(" & ") + ) + _uiState.update { result.toUiState() } + } + } + + fun reset() { + _uiState.update { NlpUiState.Idle } + } + + private fun NlpCommandResult.toUiState(): NlpUiState = when (this) { + is NlpCommandResult.Success -> NlpUiState.Success(message) + is NlpCommandResult.Error -> NlpUiState.Error(message) + is NlpCommandResult.PendingConfirmation -> NlpUiState.PendingConfirmation( + message = message, + filePaths = songFilePaths, + intent = confirmedIntent + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt index 8198b71062..adad65cbee 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/ExperimentalSettingsScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.rounded.Rectangle import androidx.compose.material.icons.rounded.Title import androidx.compose.material.icons.rounded.ViewCarousel import androidx.compose.material.icons.rounded.BlurOn -import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -788,9 +787,6 @@ fun ExperimentalSettingsScreen( } } } - } - } - item(key = "experimental_bottom_spacer") { Spacer(modifier = Modifier.height(MiniPlayerHeight + 36.dp)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cb4e6d908..03f4cfe7df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Home Search Library + Smart Special Permission Required diff --git a/app/src/test/java/com/theveloper/pixelplay/data/nlp/NlpCommandParserTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/nlp/NlpCommandParserTest.kt new file mode 100644 index 0000000000..81f355d5f6 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/nlp/NlpCommandParserTest.kt @@ -0,0 +1,130 @@ +package com.theveloper.pixelplay.data.nlp + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NlpCommandParserTest { + + @Test + fun `parse CreatePlaylist commands with various formats`() { + val testCases = listOf( + "create playlist of Nirvana" to listOf("Nirvana"), + "create a playlist for Queen" to listOf("Queen"), + "make a playlist from the beatles" to listOf("the beatles"), + "build playlist Linkin Park" to listOf("Linkin Park"), + "generate playlist with Eminem" to listOf("Eminem"), + "create playlist called jazz" to listOf("jazz"), + "create a playlist of Pop Smoke and Nirvana" to listOf("Pop Smoke", "Nirvana"), + "make a playlist from Queen, the beatles and Eminem" to listOf("Queen", "the beatles", "Eminem"), + "create playlist of Nirvana's songs" to listOf("Nirvana"), + "create playlist of songs by Queen" to listOf("Queen"), + "create playlist of Ashvan's music and Pop Smoke's tracks" to listOf("Ashvan", "Pop Smoke") + ) + + for ((input, expectedTargets) in testCases) { + val intent = NlpCommandParser.parse(input) + assertTrue(intent is NlpCommandIntent.CreatePlaylist, "Failed on input: $input") + val createPlaylist = intent as NlpCommandIntent.CreatePlaylist + assertEquals(expectedTargets, createPlaylist.targetQueries) + } + } + + @Test + fun `parse DeleteArtist commands with various formats`() { + val testCases = listOf( + "delete artist Linkin Park" to listOf("Linkin Park"), + "delete singer Michael Jackson" to listOf("Michael Jackson"), + "remove artist Eminem" to listOf("Eminem"), + "delete Nirvana" to listOf("Nirvana"), + "remove Queen" to listOf("Queen"), + "erase Nirvana" to listOf("Nirvana"), + "delete Pop Smoke and Nirvana" to listOf("Pop Smoke", "Nirvana"), + "delete Nirvana's songs" to listOf("Nirvana") + ) + + for ((input, expectedTargets) in testCases) { + val intent = NlpCommandParser.parse(input) + assertTrue(intent is NlpCommandIntent.DeleteArtist, "Failed on input: $input") + val deleteArtist = intent as NlpCommandIntent.DeleteArtist + assertEquals(expectedTargets, deleteArtist.targetQueries) + } + } + + @Test + fun `parse CategorizeGenre commands with various formats`() { + val testCases = listOf( + "categorize songs by genre rock" to listOf("rock"), + "group music by rock" to listOf("rock"), + "organize by genre jazz" to listOf("jazz"), + "categorize genre pop" to listOf("pop"), + "group songs by classical" to listOf("classical"), + "sort by genre metal" to listOf("metal"), + "group songs by rock and jazz" to listOf("rock", "jazz") + ) + + for ((input, expectedTargets) in testCases) { + val intent = NlpCommandParser.parse(input) + assertTrue(intent is NlpCommandIntent.CategorizeGenre, "Failed on input: $input") + val categorizeGenre = intent as NlpCommandIntent.CategorizeGenre + assertEquals(expectedTargets, categorizeGenre.genreNames) + } + } + + @Test + fun `parse invalid or unknown commands`() { + val invalidInputs = listOf( + "", + " ", + "play some jazz", + "find lyrics for hello", + "open settings", + "increase volume" + ) + + for (input in invalidInputs) { + val intent = NlpCommandParser.parse(input) + assertEquals(NlpCommandIntent.Unknown, intent, "Failed on input: $input") + } + } + + @Test + fun `levenshtein distance calculates correctly`() { + assertEquals(0, NlpFuzzyMatcher.levenshteinDistance("abc", "abc")) + assertEquals(1, NlpFuzzyMatcher.levenshteinDistance("Nirvna", "Nirvana")) + assertEquals(1, NlpFuzzyMatcher.levenshteinDistance("Nirvana", "Nirvna")) + assertEquals(1, NlpFuzzyMatcher.levenshteinDistance("Nirvana", "Nirvama")) + assertEquals(3, NlpFuzzyMatcher.levenshteinDistance("kitten", "sitting")) + assertEquals(5, NlpFuzzyMatcher.levenshteinDistance("intention", "execution")) + } + + @Test + fun `find best fuzzy match within threshold`() { + val candidates = listOf("Nirvana", "Linkin Park", "Queen", "The Beatles", "Eminem") + + assertEquals("Nirvana", NlpFuzzyMatcher.findBestMatch("Nirvana", candidates)) + assertEquals("Nirvana", NlpFuzzyMatcher.findBestMatch("nirvana", candidates)) + + assertEquals("Linkin Park", NlpFuzzyMatcher.findBestMatch("Linkin", candidates)) + + assertEquals("Nirvana", NlpFuzzyMatcher.findBestMatch("Nirvna", candidates)) + assertEquals("Queen", NlpFuzzyMatcher.findBestMatch("Quen", candidates)) + + assertNull(NlpFuzzyMatcher.findBestMatch("Radiohead", candidates)) + assertNull(NlpFuzzyMatcher.findBestMatch("Queeeen", candidates)) + } + + @Test + fun `find all fuzzy matches sorted by distance`() { + val candidates = listOf("Rock", "Hard Rock", "Classic Rock", "Pop", "Jazz", "Punk Rock") + + val matches = NlpFuzzyMatcher.findAllMatches("rock", candidates) + + assertEquals(4, matches.size) + assertEquals("Rock", matches[0]) + assertTrue(matches.contains("Hard Rock")) + assertTrue(matches.contains("Classic Rock")) + assertTrue(matches.contains("Punk Rock")) + } +}