Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
66be53c
perf: defer legacy migrations and non-critical loads out of init hot …
VoidX3D Jun 28, 2026
b4689a6
perf: serialize library data loading to avoid I/O contention on slow …
VoidX3D Jun 28, 2026
37ef4d2
perf: derive isLibraryContentEmpty from in-memory state instead of Ro…
VoidX3D Jun 28, 2026
7b9407d
perf: eliminate loading state flapping during sequential library load
VoidX3D Jun 28, 2026
898f756
perf: add distinctUntilChanged to playerUiState combine collectors
VoidX3D Jun 28, 2026
80fb3bf
feat(ai): add LYRICS prompt type for proper logging of translation re…
VoidX3D Jun 28, 2026
d799cd9
feat(settings): split AI settings into AI Provider tab and Generation…
VoidX3D Jun 28, 2026
08e941e
feat(ai): add SearchableProviderSelector with descriptions and live s…
VoidX3D Jun 28, 2026
b10358a
i18n: add generation parameters strings to all 11 locale files
VoidX3D Jun 28, 2026
5b0444c
cleanup: remove unused hasGeminiApiKey, songCountFlow, and repository…
VoidX3D Jun 28, 2026
273f6d9
fix: add missing LYRICS and GENERATION_PARAMETERS branches in when ex…
VoidX3D Jun 28, 2026
61702e2
fix: add missing @OptIn for ExperimentalMaterial3Api on SearchablePro…
VoidX3D Jun 28, 2026
51209dc
fix: use file-level @OptIn for ExperimentalMaterial3Api in SettingsCo…
VoidX3D Jun 28, 2026
e3e1b19
fix(ai): add live status feedback and fix toast buffer for AI generation
VoidX3D Jun 28, 2026
c8c06db
fix(search): eliminate tab-switch lag by decoupling from monolithic p…
VoidX3D Jun 28, 2026
b516f83
feat(ai): update all provider names, descriptions, endpoints, and def…
VoidX3D Jun 28, 2026
1d83e43
fix(ai): clean up stale error propagation using ConcurrentHashMap for…
VoidX3D Jun 28, 2026
c0d0614
fix: add missing import for kotlinx.coroutines.flow.first
VoidX3D Jun 28, 2026
fa9f499
fix(ai): update AI client creation to support configurable base URLs …
VoidX3D Jun 28, 2026
2b509c2
fix(ai): strip markdown formatting from lyrics responses and dynamica…
VoidX3D Jun 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import timber.log.Timber
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -27,9 +28,9 @@ class AiHandler @Inject constructor(
private val promptEngine: AiSystemPromptEngine,
@AppScope private val appScope: CoroutineScope
) {
// Cooldown timer: Provider -> Expiry Timestamp
private val providerCooldowns = mutableMapOf<AiProvider, Long>()
private val COOLDOWN_DURATION_MS = 1000L * 60 * 5 // 5 minutes
// Cooldown timer: Provider -> Expiry Timestamp (thread-safe, auto-cleans expired)
private val providerCooldowns = ConcurrentHashMap<AiProvider, Long>()
private val COOLDOWN_DURATION_MS = 1000L * 60 * 2 // 2 minutes (was 5)

// Cache TTL: 30 minutes β€” prevents stale results from being served indefinitely
private val CACHE_TTL_MS = 1000L * 60 * 30
Expand Down Expand Up @@ -97,7 +98,13 @@ class AiHandler @Inject constructor(
presencePenalty: Float,
frequencyPenalty: Float,
): GenerationResult {
val client = clientFactory.createClient(provider, apiKey)
val client = if (provider.hasConfigurableUrl) {
val configuredUrl = preferencesRepo.getBaseUrl(provider).first()
if (configuredUrl.isNotBlank()) clientFactory.createClientWithUrl(provider, apiKey, configuredUrl)
else clientFactory.createClient(provider, apiKey)
} else {
clientFactory.createClient(provider, apiKey)
}
val requestedModel = getModel(provider).ifBlank { client.getDefaultModel() }

suspend fun callWithModel(model: String): String {
Expand Down Expand Up @@ -163,6 +170,18 @@ class AiHandler @Inject constructor(
context: String = ""
): String {
val params = getGenerationParams()
val effectiveMaxTokens = if (type == AiSystemPromptType.LYRICS) {
// Lyrics translation needs more output tokens because each original
// line is followed by its translation (2x output). Also factor in
// the full lyrics text in the prompt. Use at least 4096, at most
// 16384, scaling linearly with input length.
val estimatedInputChars = prompt.length
val estimatedOutputChars = estimatedInputChars * 2
val estimatedOutputTokens = (estimatedOutputChars / 4).coerceAtLeast(4096)
estimatedOutputTokens.coerceAtMost(16384)
} else {
params.maxTokens
}
val effectiveTemperature = if (params.temperature == 0.7f) {
if (temperature == 0.7f) {
when (type) {
Expand All @@ -171,6 +190,7 @@ class AiHandler @Inject constructor(
AiSystemPromptType.TAGGING -> 0.4f
AiSystemPromptType.PLAYLIST, AiSystemPromptType.DAILY_MIX -> 0.6f
AiSystemPromptType.PERSONA -> 0.85f
AiSystemPromptType.LYRICS -> 0.7f
AiSystemPromptType.GENERAL -> 0.7f
}
} else temperature
Expand All @@ -191,9 +211,12 @@ class AiHandler @Inject constructor(
}
}

// Clean up expired cooldowns so stale entries never accumulate
val now = System.currentTimeMillis()
providerCooldowns.entries.removeIf { it.value < now }

val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider)
val failedProviders = mutableListOf<String>()
val now = System.currentTimeMillis()

for (provider in providersToTry) {
val cooldownExpiry = providerCooldowns[provider] ?: 0L
Expand All @@ -204,7 +227,7 @@ class AiHandler @Inject constructor(

try {
val apiKey = getApiKey(provider)
if (apiKey.isBlank()) {
if (apiKey.isBlank() && provider.requiresApiKey) {
failedProviders.add("${provider.name}: no API key configured")
continue
}
Expand All @@ -220,7 +243,7 @@ class AiHandler @Inject constructor(
temperature = effectiveTemperature,
topP = params.topP,
topK = params.topK,
maxTokens = params.maxTokens,
maxTokens = effectiveMaxTokens,
presencePenalty = params.presencePenalty,
frequencyPenalty = params.frequencyPenalty,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,27 @@ object AiResponseCleaner {
}

fun cleanTextResponse(raw: String): String {
return raw
var cleaned = raw
.replace("```text", "")
.replace("```", "")
.trim()

// Remove conversational framing lines that AIs sometimes prepend
val framingPrefixes = listOf(
"Here is", "Here's", "Here are", "Sure", "Certainly",
"Of course", "I've", "I have", "The translated", "Translation:",
"Translated lyrics:", "Output:"
)
val framingPattern = framingPrefixes.joinToString("|") { Regex.escape(it) }
cleaned = cleaned.replace(Regex("^(?:$framingPattern).*\\n?", RegexOption.MULTILINE), "")

// Strip paired markdown formatting markers (**text** or __text__)
cleaned = cleaned
.replace(Regex("\\*\\*(.*?)\\*\\*"), "$1")
.replace(Regex("__(.*?)__"), "$1")
.trim()

return cleaned
}

fun extractJsonArray(text: String): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum class AiSystemPromptType {
MOOD_ANALYSIS,
PERSONA,
DAILY_MIX,
LYRICS,
GENERAL
}

Expand Down Expand Up @@ -182,6 +183,16 @@ class AiSystemPromptEngine @Inject constructor() {
$dailyMixPersonaPrompt
""".trimIndent()

AiSystemPromptType.LYRICS -> """
<role>Song lyrics translator β€” you translate lyrics between languages while preserving structure.</role>
<strategy>
- Preserve ALL timestamps and line structure exactly.
- Output each original line followed by its translation on the next line.
- Never add explanations, labels, or formatting beyond the requested format.
- If the source is already in the target language, respond with: ALREADY_IN_TARGET_LANGUAGE
</strategy>
""".trimIndent()

AiSystemPromptType.GENERAL -> """
<role>PixelPlayer Assistant β€” a knowledgeable music companion.</role>
<strategy>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,47 +16,47 @@ class AiClientFactory @Inject constructor() {
* @return AiClient instance
*/
fun createClient(provider: AiProvider, apiKey: String): AiClient {
if (apiKey.isBlank()) {
if (apiKey.isBlank() && provider.requiresApiKey) {
throw IllegalArgumentException("API Key cannot be blank for ${provider.displayName}")
}

return when (provider) {
AiProvider.GEMINI -> GeminiAiClient(apiKey)
AiProvider.DEEPSEEK -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.deepseek.com",
baseUrl = "https://api.deepseek.com/v1",
defaultModelId = "deepseek-chat",
providerName = "DeepSeek"
)
AiProvider.GROQ -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.groq.com/openai/v1",
defaultModelId = "llama-3.1-8b-instant",
defaultModelId = "llama-3.3-70b-versatile",
providerName = "Groq"
)
AiProvider.MISTRAL -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.mistral.ai/v1",
defaultModelId = "mistral-large-latest",
defaultModelId = "mistral-large-2411",
providerName = "Mistral"
)
AiProvider.NVIDIA -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://integrate.api.nvidia.com/v1",
defaultModelId = "meta/llama-3.1-8b-instruct",
defaultModelId = "nvidia/llama-3.1-nemotron-70b-instruct",
providerName = "NVIDIA NIM"
)
AiProvider.KIMI -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://api.moonshot.cn/v1",
defaultModelId = "moonshot-v1-8k",
providerName = "Moonshot Kimi"
defaultModelId = "moonshot-v1-auto",
providerName = "Kimi"
)
AiProvider.GLM -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://open.bigmodel.cn/api/paas/v4",
defaultModelId = "glm-4",
providerName = "Zhipu GLM"
defaultModelId = "glm-4-plus",
providerName = "GLM"
)
AiProvider.OPENAI -> GenericOpenAiClient(
apiKey = apiKey,
Expand All @@ -67,7 +67,7 @@ class AiClientFactory @Inject constructor() {
AiProvider.OPENROUTER -> GenericOpenAiClient(
apiKey = apiKey,
baseUrl = "https://openrouter.ai/api/v1",
defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free",
defaultModelId = "google/gemini-2.5-flash-preview-04-17:free",
providerName = "OpenRouter"
)
AiProvider.OLLAMA -> GenericOpenAiClient(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package com.theveloper.pixelplay.data.ai.provider

/**
* Enum representing available AI providers
*/
enum class AiProvider(val displayName: String, val requiresApiKey: Boolean, val hasConfigurableUrl: Boolean = false) {
GEMINI("Google Gemini", requiresApiKey = true),
DEEPSEEK("DeepSeek", requiresApiKey = true),
GROQ("Groq", requiresApiKey = true),
MISTRAL("Mistral", requiresApiKey = true),
NVIDIA("NVIDIA NIM", requiresApiKey = true),
KIMI("Kimi (Moonshot)", requiresApiKey = true),
GLM("Zhipu GLM", requiresApiKey = true),
OPENAI("OpenAI", requiresApiKey = true),
OPENROUTER("OpenRouter", requiresApiKey = true),
OLLAMA("Ollama", requiresApiKey = true),
CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true);
enum class AiProvider(
val displayName: String,
val requiresApiKey: Boolean,
val hasConfigurableUrl: Boolean = false,
val description: String = ""
) {
GEMINI("Google Gemini", requiresApiKey = true, description = "Gemini 2.5 Pro/Flash β€” Google's latest multimodal models"),
DEEPSEEK("DeepSeek", requiresApiKey = true, description = "DeepSeek-V3 & R1 β€” competitive open-weight reasoning models"),
GROQ("Groq", requiresApiKey = true, description = "Llama 3, Mixtral, Gemma β€” ultra-fast LPU inference"),
MISTRAL("Mistral", requiresApiKey = true, description = "Mistral Large, Small, Codestral β€” efficient European models"),
NVIDIA("NVIDIA NIM", requiresApiKey = true, description = "NVIDIA-optimized Llama, Nemotron, and community models"),
KIMI("Kimi (Moonshot)", requiresApiKey = true, description = "Kimi k1.5 β€” long-context reasoning by Moonshot AI"),
GLM("Zhipu GLM", requiresApiKey = true, description = "GLM-4 β€” bilingual (Chinese/English) models by Zhipu AI"),
OPENAI("OpenAI", requiresApiKey = true, description = "GPT-4o, GPT-4.1, o3, o4-mini β€” industry-standard models"),
OPENROUTER("OpenRouter", requiresApiKey = true, description = "Single API for 300+ models across all major providers"),
OLLAMA("Ollama", requiresApiKey = true, hasConfigurableUrl = true, description = "Ollama-compatible API endpoint (configurable URL)"),
CUSTOM("Custom Provider", requiresApiKey = true, hasConfigurableUrl = true, description = "Any OpenAI-compatible API endpoint");

companion object {
fun fromString(value: String): AiProvider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ interface MusicRepository {
storageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL
): Flow<Int>

/**
* Returns the count of songs in the library.
* @return Flow emitting the current song count.
*/
fun getSongCountFlow(): Flow<Int>

/**
* Returns the count of cloud songs in the library.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,6 @@ class MusicRepositoryImpl @Inject constructor(
return songRepository.getFavoriteSongCountFlow(storageFilter)
}

override fun getSongCountFlow(): Flow<Int> {
return musicDao.getSongCount().distinctUntilChanged()
}

override fun getCloudSongCountFlow(): Flow<Int> {
return musicDao.getCloudSongCount().distinctUntilChanged()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -301,6 +302,7 @@ private fun CreationModeCard(
fun CreateAiPlaylistDialog(
visible: Boolean,
isGenerating: Boolean,
status: String?,
error: String?,
onDismiss: () -> Unit,
onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit
Expand Down Expand Up @@ -328,6 +330,7 @@ fun CreateAiPlaylistDialog(
) {
CreateAiPlaylistContent(
isGenerating = isGenerating,
status = status,
error = error,
onDismiss = onDismiss,
onGenerate = onGenerate
Expand All @@ -340,6 +343,7 @@ fun CreateAiPlaylistDialog(
@Composable
private fun CreateAiPlaylistContent(
isGenerating: Boolean,
status: String?,
error: String?,
onDismiss: () -> Unit,
onGenerate: (playlistName: String?, prompt: String, minLength: Int, maxLength: Int) -> Unit
Expand Down Expand Up @@ -525,6 +529,28 @@ private fun CreateAiPlaylistContent(
) {
Spacer(modifier = Modifier.height(4.dp))

if (isGenerating && !status.isNullOrBlank()) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
LoadingIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}

HeroAiCard()

AiSectionCard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.ui.graphics.vector.ImageVector
import com.theveloper.pixelplay.R

Expand Down Expand Up @@ -49,6 +50,12 @@ enum class SettingsCategory(
subtitleRes = R.string.settings_category_ai_subtitle,
iconRes = R.drawable.gemini_ai
),
GENERATION_PARAMETERS(
id = "generation_parameters",
titleRes = R.string.settings_category_generation_parameters_title,
subtitleRes = R.string.settings_category_generation_parameters_subtitle,
icon = Icons.Rounded.Tune
),
BACKUP_RESTORE(
id = "backup_restore",
titleRes = R.string.settings_category_backup_title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ private fun formatPromptType(type: String): String {
"MOOD_ANALYSIS" -> "Analysis"
"PERSONA" -> "Persona"
"DAILY_MIX" -> "Daily Mix"
"LYRICS" -> "Lyrics"
"GENERAL" -> "General"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ fun LibraryScreen(
}.collectAsStateWithLifecycle(initialValue = false)
val hasActiveAiProviderApiKey by playerViewModel.hasActiveAiProviderApiKey.collectAsStateWithLifecycle()
val isGeneratingAiPlaylist by playerViewModel.isGeneratingAiPlaylist.collectAsStateWithLifecycle()
val aiStatus by playerViewModel.aiStatus.collectAsStateWithLifecycle()
val aiError by playerViewModel.aiError.collectAsStateWithLifecycle()
var showCreatePlaylistDialog by remember { mutableStateOf(false) }
var showPlaylistCreationTypeDialog by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -1002,11 +1003,11 @@ fun LibraryScreen(
}.collectAsStateWithLifecycle(initialValue = LibraryScreenPlayerProjection())
val isLibraryContentEmpty by remember(playerViewModel) {
combine(
playerViewModel.songCountFlow,
playerViewModel.allSongsFlow,
playerViewModel.albumsFlow,
playerViewModel.artistsFlow
) { songCount, albums, artists ->
songCount == 0 && albums.isEmpty() && artists.isEmpty()
) { songs, albums, artists ->
songs.isEmpty() && albums.isEmpty() && artists.isEmpty()
}.distinctUntilChanged()
}.collectAsStateWithLifecycle(initialValue = true)

Expand Down Expand Up @@ -1800,6 +1801,7 @@ fun LibraryScreen(
CreateAiPlaylistDialog(
visible = showCreateAiPlaylistDialog && hasActiveAiProviderApiKey,
isGenerating = isGeneratingAiPlaylist,
status = aiStatus,
error = aiError,
onDismiss = {
showCreateAiPlaylistDialog = false
Expand Down
Loading
Loading