Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
db454bc
Add Japanese translations
daedaevibin Jun 24, 2026
7e927c0
Add MultiLangRomanizer functions for Thai, Arabic, Greek, Hebrew, and…
daedaevibin Jun 24, 2026
f53d857
Add romanization and language detection for Cyrillic and Vietnamese
daedaevibin Jun 24, 2026
d74f593
Fix 21 pre-existing lint errors
daedaevibin Jun 24, 2026
2580e86
Add new app languages and update locale configuration
daedaevibin Jun 24, 2026
5a90005
Add LYRICS settings category and restructure settings screens
daedaevibin Jun 24, 2026
a88c9a1
Update CI workflows and build config
daedaevibin Jun 24, 2026
4940904
Misc fixes: AI max tokens and lyrics loading priority
daedaevibin Jun 24, 2026
0f4045b
Add ServerUrlUtils, Vietnamese/Traditional Chinese translations, and …
daedaevibin Jun 24, 2026
54dca07
Merge remote-tracking branch 'upstream/master'
daedaevibin Jun 24, 2026
b98c567
chore(deps): bump the github-actions group with 2 updates
dependabot[bot] Jun 24, 2026
dce462d
chore(deps): bump the gradle-dependencies group with 2 updates
dependabot[bot] Jun 24, 2026
2bfbd3b
Merge pull request #45 from Veridian-Zenith/dependabot/github_actions…
daedaevibin Jun 25, 2026
b6b2096
Merge pull request #46 from Veridian-Zenith/dependabot/gradle/gradle-…
daedaevibin Jun 25, 2026
8607ca0
File cleanup
daedaevibin Jun 25, 2026
ea72d88
chore(deps): bump the gradle-dependencies group across 1 directory wi…
dependabot[bot] Jun 26, 2026
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
8eac816
Merge pull request #48 from Veridian-Zenith/dependabot/gradle/gradle-…
daedaevibin Jun 28, 2026
e3e1b19
fix(ai): add live status feedback and fix toast buffer for AI generation
VoidX3D Jun 28, 2026
86422df
fix(wear): add error_prone_annotations dependency for Dagger 2.60 gen…
daedaevibin 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
a10cfe5
chore(deps): bump the gradle-dependencies group with 6 updates
dependabot[bot] Jun 29, 2026
a44b927
Merge pull request #50 from Veridian-Zenith/dependabot/gradle/gradle-…
daedaevibin Jun 30, 2026
b6517b9
chore(deps): bump the gradle-dependencies group across 1 directory wi…
dependabot[bot] Jul 2, 2026
6071439
Merge VoidX3D PR #2497 into review-pr-2497
daedaevibin Jul 5, 2026
523c8f9
Fix: CJK token scaling, embedded lyrics reading, local .lrc Unicode m…
daedaevibin Jul 5, 2026
1a0361d
fix: suppress NonObservableLocale lint in AiUsage date format
daedaevibin Jul 5, 2026
f774525
fix: lyrics translation cutoff, false already-translated, search dial…
daedaevibin Jul 6, 2026
acd1ce7
Fix #58, #64, #67: Shared AI OkHttpClient, FLAC fallback, provider de…
daedaevibin Jul 6, 2026
2f1df76
Fix compilation: add TimeUnit import, rename httpClient param to client
daedaevibin Jul 6, 2026
4658ca3
Fix compilation errors, Song.id type safety, dead code removal, and c…
daedaevibin Jul 6, 2026
c297256
Add opencode workflow for testing and extra analysis
daedaevibin Jul 7, 2026
ea42498
chore(deps): bump actions/checkout in the github-actions group
dependabot[bot] Jul 7, 2026
8a8b18e
chore(deps): bump the gradle-dependencies group across 1 directory wi…
dependabot[bot] Jul 7, 2026
82ab444
update opencode workflow
daedaevibin Jul 8, 2026
f73beee
Merge pull request #69 from Veridian-Zenith/dependabot/gradle/gradle-…
daedaevibin Jul 8, 2026
c9b99a0
Merge pull request #70 from Veridian-Zenith/dependabot/github_actions…
daedaevibin Jul 8, 2026
bc1fbf9
Remove opencode yaml
daedaevibin Jul 10, 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
19 changes: 8 additions & 11 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,32 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: 'enhanced'

- name: Pin Kotlin version for CodeQL compatibility
run: |
# More robust sed pattern that handles various formats
sed -i 's/kotlin = "[^"]*"/kotlin = "2.3.20"/g' gradle/libs.versions.toml

# Verify the change was made
grep 'kotlin = "2.3.20"' gradle/libs.versions.toml


- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: 'enhanced'

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: java-kotlin
build-mode: manual

- name: Build with Gradle
run: ${{ github.workspace }}/gradlew :app:assembleDebug :wear:assembleDebug --no-daemon
run: ${{ github.workspace }}/gradlew :app:assembleDebug :wear:assembleDebug --no-daemon --no-build-cache

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/nightly-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
Expand All @@ -98,7 +98,7 @@ jobs:

- name: Cache nightly keystore
id: cache-keystore
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: vz-pixelplay.jks
key: ${{ runner.os }}-nightly-keystore-vz-pixelplay
Expand All @@ -117,7 +117,7 @@ jobs:
echo "keyPassword=994273" >> keystore.properties

- name: Build Phone nightly release APKs
run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true
run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true --no-build-cache

- name: Verify Phone nightly split APKs
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/phone-debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
Expand All @@ -30,7 +30,7 @@ jobs:
uses: gradle/actions/setup-gradle@v6

- name: Build Phone debug APK
run: gradle :app:assembleDebug -Ppixelplay.enableAbiSplits=true
run: gradle :app:assembleDebug -Ppixelplay.enableAbiSplits=true --no-build-cache

- name: Verify Phone split APKs
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/phone-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
Expand All @@ -31,7 +31,7 @@ jobs:

- name: Cache keystore
id: cache-keystore
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: vz-pixelplay.jks
key: ${{ runner.os }}-keystore-vz-pixelplay
Expand All @@ -52,7 +52,7 @@ jobs:
echo "keyPassword=994273" >> keystore.properties

- name: Build Phone release APK
run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true
run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true --no-build-cache

- name: Verify Phone split APKs
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/wearos-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up JDK 21
uses: actions/setup-java@v5
Expand All @@ -30,7 +30,7 @@ jobs:
uses: gradle/actions/setup-gradle@v6

- name: Build Wear OS debug APK
run: gradle :wear:assembleDebug
run: gradle :wear:assembleDebug --no-build-cache

- name: Upload Wear OS APK artifact
uses: actions/upload-artifact@v7.0.1
Expand Down
1 change: 0 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,6 @@ dependencies {

// UI Utilities & Extra
implementation(libs.timber)
implementation(libs.generativeai)
implementation(libs.smooth.corner.rect.android.compose)
implementation(libs.reorderables)
implementation(libs.codeview)
Expand Down
20 changes: 20 additions & 0 deletions app/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- Translation completeness cannot be enforced without native speakers -->
<issue id="MissingTranslation" severity="warning" />
<issue id="ExtraTranslation" severity="warning" />
<issue id="MissingQuantity" severity="warning" />
<issue id="ImpliedQuantity" severity="warning" />

<!-- Intentional use of Material Design 3 internal color APIs for dynamic theme -->
<issue id="RestrictedApi" severity="warning" />

<!-- Media3 APIs marked @UnstableApi are used intentionally throughout -->
<issue id="UnsafeOptInUsageError" severity="warning" />

<!-- Compose resource access via LocalContext.current is accepted pattern -->
<issue id="LocalContextGetResourceValueCall" severity="warning" />

<!-- Missing default resource -- resolved by adding declarations to base values/ -->
<issue id="MissingDefaultResource" severity="error" />
</lint>
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
<action android:name="com.theveloper.pixelplay.action.OPEN_PLAYER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class ExternalPlayerActivity : ComponentActivity() {
return intent.data
}

@Suppress("WrongConstant")
private fun persistUriPermissionIfNeeded(intent: Intent, uri: Uri) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val hasPersistablePermission = intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.Context
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import android.graphics.RenderEffect as AndroidRenderEffect
import android.graphics.Shader as AndroidShader
import androidx.compose.ui.graphics.asComposeRenderEffect
Expand Down Expand Up @@ -943,6 +944,7 @@ class MainActivity : ComponentActivity() {
val expansionFractionProvider = remember(playerViewModel.playerContentExpansionFraction) {
{ playerViewModel.playerContentExpansionFraction.value }
}
@Suppress("NewApi")
val blurEffectCache = remember { BlurEffectCache() }

Box(
Expand Down Expand Up @@ -1152,10 +1154,12 @@ class MainActivity : ComponentActivity() {
* blur every animation frame. The radius is quantized at the call site, so this
* only rebuilds ~25 times across the whole expand animation instead of 60+/sec.
*/
@RequiresApi(Build.VERSION_CODES.S)
private class BlurEffectCache {
private var lastRadiusPx: Float = Float.NaN
private var cached: androidx.compose.ui.graphics.RenderEffect? = null

@RequiresApi(Build.VERSION_CODES.S)
fun get(radiusPx: Float): androidx.compose.ui.graphics.RenderEffect? {
if (radiusPx <= 0f) {
lastRadiusPx = 0f
Expand Down
63 changes: 47 additions & 16 deletions app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.theveloper.pixelplay.data.ai


import com.theveloper.pixelplay.data.ai.provider.AiClient
import com.theveloper.pixelplay.data.ai.provider.AiClientFactory
import com.theveloper.pixelplay.data.ai.provider.AiProvider
import com.theveloper.pixelplay.data.ai.provider.AiProviderException
import com.theveloper.pixelplay.data.ai.provider.AiProviderSupport
import com.theveloper.pixelplay.data.database.AiCacheDao
import com.theveloper.pixelplay.data.database.AiCacheEntity
import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository
Expand All @@ -12,9 +15,11 @@ import com.theveloper.pixelplay.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.TimeoutCancellationException
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 +32,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 +102,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 All @@ -108,8 +119,8 @@ class AiHandler @Inject constructor(
topP, topK, maxTokens, presencePenalty, frequencyPenalty,
)
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
throw com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.createException(
} catch (e: TimeoutCancellationException) {
throw AiProviderSupport.createException(
providerName = provider.displayName,
statusCode = null,
transportMessage = "Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s. The model may be overloaded.",
Expand All @@ -123,7 +134,7 @@ class AiHandler @Inject constructor(
val response = callWithModel(requestedModel)
GenerationResult(response, requestedModel)
} catch (e: Exception) {
val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(
val failure = AiProviderSupport.wrapThrowable(
provider.displayName, e, requestedModel
)

Expand All @@ -140,13 +151,13 @@ class AiHandler @Inject constructor(
provider: AiProvider,
apiKey: String,
requestedModel: String,
client: com.theveloper.pixelplay.data.ai.provider.AiClient,
failure: com.theveloper.pixelplay.data.ai.provider.AiProviderException
client: AiClient,
failure: AiProviderException
): String? {
if (!failure.isModelUnavailable()) return null

val availableModels = runCatching { client.getAvailableModels(apiKey) }.getOrDefault(emptyList())
val recoveredModel = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.selectRecoveryModel(
val recoveredModel = AiProviderSupport.selectRecoveryModel(
currentModel = requestedModel,
defaultModel = client.getDefaultModel(),
availableModels = availableModels
Expand All @@ -163,6 +174,17 @@ class AiHandler @Inject constructor(
context: String = ""
): String {
val params = getGenerationParams()
val effectiveMaxTokens = if (type == AiSystemPromptType.LYRICS) {
val estimatedInputChars = prompt.length
// Translation doubles output (original + translated per line), plus formatting overhead
val estimatedOutputChars = estimatedInputChars * 3
val cjkRatio = prompt.count { it.code in 0x4E00..0x9FFF || it.code in 0x3040..0x30FF || it.code in 0xAC00..0xD7AF || it.code in 0x0E00..0x0E7F || it.code in 0x0600..0x06FF || it.code in 0x0370..0x03FF || it.code in 0x0590..0x05FF || it.code in 0x0900..0x097F || it.code in 0x0A00..0x0A7F }.toFloat() / estimatedInputChars.coerceAtLeast(1)
val charsPerToken = (4f - cjkRatio * 3f).coerceIn(1f, 4f)
val estimatedOutputTokens = (estimatedOutputChars / charsPerToken).toInt().coerceAtLeast(8192)
estimatedOutputTokens.coerceAtMost(32768)
} else {
params.maxTokens
}
val effectiveTemperature = if (params.temperature == 0.7f) {
if (temperature == 0.7f) {
when (type) {
Expand All @@ -171,6 +193,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 +214,12 @@ class AiHandler @Inject constructor(
}
}

val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider)
val failedProviders = mutableListOf<String>()
// Clean up expired cooldowns so stale entries never accumulate
val now = System.currentTimeMillis()
providerCooldowns.entries.removeIf { it.value < now }

val providersToTry = AiProviderSupport.buildProviderChain(userProvider)
val failedProviders = mutableListOf<String>()

for (provider in providersToTry) {
val cooldownExpiry = providerCooldowns[provider] ?: 0L
Expand All @@ -204,7 +230,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 +246,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 All @@ -230,7 +256,7 @@ class AiHandler @Inject constructor(
continue
}

val isThinkingModel = finalSystemPrompt.contains("think", true) || provider.name.contains("reasoning", true)
val isThinkingModel = result.modelUsed.contains("think", true) || result.modelUsed.contains("reason", true) || provider.name.contains("reasoning", true)
val estimatedPromptTokens = (finalSystemPrompt.length + prompt.length) / 4
val estimatedOutputTokens = result.response.length / 4
val estimatedThoughtTokens = if (isThinkingModel) (estimatedOutputTokens * 1.5).toInt() else 0
Expand All @@ -254,10 +280,15 @@ class AiHandler @Inject constructor(
}

cacheDao.insert(AiCacheEntity(promptHash = hash, responseJson = result.response, timestamp = System.currentTimeMillis()))

// Evict cache entries older than 7 days
val sevenDaysAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
try { cacheDao.clearOldCache(sevenDaysAgo) } catch (_: Exception) {}

return result.response
} catch (e: Exception) {
// AI Optimization: Robust failover logic—if one provider fails, we log and try the next in the chain
val failure = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.wrapThrowable(provider.displayName, e)
val failure = AiProviderSupport.wrapThrowable(provider.displayName, e)
Timber.tag("AiHandler").w(e, "Provider ${provider.name} failed: ${failure.message}")
failedProviders.add("${provider.name}: ${failure.message ?: "Unknown error"}")
// Trigger cooldown only on provider-level outages and account problems.
Expand Down
Loading
Loading