diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b2793cfd88..686149cc8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,37 +21,34 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.7.0 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 + uses: github/codeql-action/init@v4.37.4 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 + uses: github/codeql-action/analyze@v4.37.4 with: category: "/language:java-kotlin" diff --git a/.github/workflows/nightly-apk.yml b/.github/workflows/nightly-apk.yml index 2c6f7728f5..c532ad1a18 100644 --- a/.github/workflows/nightly-apk.yml +++ b/.github/workflows/nightly-apk.yml @@ -85,10 +85,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.7.0 with: distribution: temurin java-version: '21' @@ -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 @@ -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: | diff --git a/.github/workflows/phone-debug.yml b/.github/workflows/phone-debug.yml index 3f7d70fb2e..8bec0f7e69 100644 --- a/.github/workflows/phone-debug.yml +++ b/.github/workflows/phone-debug.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.7.0 with: distribution: temurin java-version: '21' @@ -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: | diff --git a/.github/workflows/phone-release.yml b/.github/workflows/phone-release.yml index 1c33965a3e..2378c9fd08 100644 --- a/.github/workflows/phone-release.yml +++ b/.github/workflows/phone-release.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.7.0 with: distribution: temurin java-version: '21' @@ -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 @@ -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: | diff --git a/.github/workflows/wearos-apk.yml b/.github/workflows/wearos-apk.yml index 6e7b53b6da..a09939e01d 100644 --- a/.github/workflows/wearos-apk.yml +++ b/.github/workflows/wearos-apk.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.7.0 with: distribution: temurin java-version: '21' @@ -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 diff --git a/.gitignore b/.gitignore index 986f70d62c..1510df9893 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ markdown.xml **/.env **/.env.* !**/.env.example +message.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f2583065f..a52fd8bda2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,9 +110,9 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" val telegramApiId = localProperties.getProperty("TELEGRAM_API_ID")?.ifEmpty { null } - ?: "2040" + ?: error("TELEGRAM_API_ID not found in local.properties. Add TELEGRAM_API_ID= to local.properties.") val telegramApiHash = localProperties.getProperty("TELEGRAM_API_HASH")?.ifEmpty { null } - ?: "b18441a1ff607e10a989891a5462e627" + ?: error("TELEGRAM_API_HASH not found in local.properties. Add TELEGRAM_API_HASH= to local.properties.") buildConfigField("int", "TELEGRAM_API_ID", telegramApiId) buildConfigField("String", "TELEGRAM_API_HASH", "\"$telegramApiHash\"") } @@ -325,7 +325,6 @@ dependencies { // UI Utilities & Extra implementation(libs.timber) - implementation(libs.generativeai) implementation(libs.smooth.corner.rect.android.compose) implementation(libs.reorderables) implementation(libs.codeview) diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000000..6fb7e7f3ea --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1afb6f4044..46a821a28e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,10 @@ + + + + diff --git a/app/src/main/java/com/theveloper/pixelplay/ExternalPlayerActivity.kt b/app/src/main/java/com/theveloper/pixelplay/ExternalPlayerActivity.kt index 2d993383df..3ffd21abfe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/ExternalPlayerActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/ExternalPlayerActivity.kt @@ -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 diff --git a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt index 6931cb6ca8..5733e8360e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/MainActivity.kt @@ -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 @@ -944,6 +945,7 @@ class MainActivity : ComponentActivity() { val expansionFractionProvider = remember(playerViewModel.playerContentExpansionFraction) { { playerViewModel.playerContentExpansionFraction.value } } + @Suppress("NewApi") val blurEffectCache = remember { BlurEffectCache() } Box( @@ -1153,10 +1155,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 diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt index d9da7840b3..6bdd82c551 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiHandler.kt @@ -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 @@ -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 @@ -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() - private val COOLDOWN_DURATION_MS = 1000L * 60 * 5 // 5 minutes + // Cooldown timer: Provider -> Expiry Timestamp (thread-safe, auto-cleans expired) + private val providerCooldowns = ConcurrentHashMap() + 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 @@ -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 { @@ -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.", @@ -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 ) @@ -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 @@ -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) { @@ -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 @@ -191,9 +214,12 @@ class AiHandler @Inject constructor( } } - val providersToTry = com.theveloper.pixelplay.data.ai.provider.AiProviderSupport.buildProviderChain(userProvider) - val failedProviders = mutableListOf() + // 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() for (provider in providersToTry) { val cooldownExpiry = providerCooldowns[provider] ?: 0L @@ -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 } @@ -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, ) @@ -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 @@ -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. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt index 92d6e27de8..2e315e4f72 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiResponseCleaner.kt @@ -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? { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt index 9edf9e4042..a103af1f07 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiSystemPromptEngine.kt @@ -11,6 +11,7 @@ enum class AiSystemPromptType { MOOD_ANALYSIS, PERSONA, DAILY_MIX, + LYRICS, GENERAL } @@ -182,6 +183,16 @@ class AiSystemPromptEngine @Inject constructor() { $dailyMixPersonaPrompt """.trimIndent() + AiSystemPromptType.LYRICS -> """ + Song lyrics translator — you translate lyrics between languages while preserving structure. + + - 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 + + """.trimIndent() + AiSystemPromptType.GENERAL -> """ PixelPlayer Assistant — a knowledgeable music companion. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt index c97959f5c3..3fbbaf1c2e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/GeminiModelService.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.data.ai +import com.theveloper.pixelplay.data.ai.provider.UnifiedModelFilter import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.worker.AiWorkerManager import kotlinx.coroutines.Dispatchers @@ -22,12 +23,7 @@ class GeminiModelService @Inject constructor( ) { companion object { - // Markers for models that cannot perform text chat generation. These are the - // only things we filter out — every other model the API returns is selectable. - private val NON_CHAT_MARKERS = listOf( - "embedding", "aqa", "imagen", "image-generation", - "tts", "audio", "veo", "vision-only", "learnlm-embedding" - ) + // Use UnifiedModelFilter for consistent model filtering across all providers. } suspend fun fetchAvailableModels(apiKey: String): Result> { @@ -48,10 +44,11 @@ class GeminiModelService @Inject constructor( private suspend fun makeModelsListRequest(apiKey: String): List { return withContext(Dispatchers.IO) { try { - val url = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey" + val url = "https://generativelanguage.googleapis.com/v1beta/models" val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection connection.requestMethod = "GET" + connection.setRequestProperty("x-goog-api-key", apiKey) connection.connectTimeout = 10000 connection.readTimeout = 10000 @@ -88,7 +85,7 @@ class GeminiModelService @Inject constructor( // version — let the user pick any chat-capable model their key supports. if ((modelName.startsWith("gemini", ignoreCase = true) || modelName.startsWith("gemma", ignoreCase = true)) && - !isNonChatModel(modelName)) { + UnifiedModelFilter.isModelUsableForChat(modelName)) { models.add(GeminiModel( name = modelName, displayName = formatDisplayName(modelName) @@ -131,11 +128,6 @@ class GeminiModelService @Inject constructor( } } - private fun isNonChatModel(modelName: String): Boolean { - val lower = modelName.lowercase() - return NON_CHAT_MARKERS.any { lower.contains(it) } - } - private fun formatDisplayName(modelName: String): String { return modelName .split("-") diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt index 348547ee68..430c425f4c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClient.kt @@ -18,11 +18,6 @@ interface AiClient { frequencyPenalty: Float = 0.0f ): String - /** - * Estimate or count tokens for a given prompt - */ - suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int - /** * Get list of available models for this provider */ diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt index 4322ac24e7..54a049f236 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactory.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.data.ai.provider +import okhttp3.OkHttpClient import javax.inject.Inject import javax.inject.Singleton @@ -7,7 +8,9 @@ import javax.inject.Singleton * Factory for creating AI client instances based on provider type */ @Singleton -class AiClientFactory @Inject constructor() { +class AiClientFactory @Inject constructor( + @com.theveloper.pixelplay.di.AiOkHttpClient private val sharedHttpClient: OkHttpClient +) { /** * Create an AI client for the specified provider @@ -16,77 +19,87 @@ 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.GEMINI -> GeminiAiClient(apiKey, sharedHttpClient) AiProvider.DEEPSEEK -> GenericOpenAiClient( apiKey = apiKey, - baseUrl = "https://api.deepseek.com", + baseUrl = "https://api.deepseek.com/v1", defaultModelId = "deepseek-chat", - providerName = "DeepSeek" + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.GROQ -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.groq.com/openai/v1", - defaultModelId = "llama-3.1-8b-instant", - providerName = "Groq" + defaultModelId = "llama-3.3-70b-versatile", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.MISTRAL -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.mistral.ai/v1", - defaultModelId = "mistral-large-latest", - providerName = "Mistral" + defaultModelId = "mistral-large-2411", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.NVIDIA -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://integrate.api.nvidia.com/v1", - defaultModelId = "meta/llama-3.1-8b-instruct", - providerName = "NVIDIA NIM" + defaultModelId = "nvidia/llama-3.1-nemotron-70b-instruct", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.KIMI -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.moonshot.cn/v1", - defaultModelId = "moonshot-v1-8k", - providerName = "Moonshot Kimi" + defaultModelId = "moonshot-v1-auto", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.GLM -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://open.bigmodel.cn/api/paas/v4", - defaultModelId = "glm-4", - providerName = "Zhipu GLM" + defaultModelId = "glm-4-plus", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.OPENAI -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://api.openai.com/v1", defaultModelId = "gpt-4o-mini", - providerName = "OpenAI" + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.OPENROUTER -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "https://openrouter.ai/api/v1", - defaultModelId = "google/gemini-2.0-flash-lite-preview-02-05:free", - providerName = "OpenRouter" + defaultModelId = "google/gemini-2.5-flash-preview-04-17:free", + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.OLLAMA -> GenericOpenAiClient( apiKey = apiKey, - baseUrl = "https://api.ollama.ai/v1", + baseUrl = "http://localhost:11434/v1", defaultModelId = "llama3", - providerName = "Ollama" + providerName = provider.displayName, + client = sharedHttpClient ) AiProvider.CUSTOM -> GenericOpenAiClient( apiKey = apiKey, baseUrl = "", defaultModelId = "", - providerName = "Custom Provider" + providerName = provider.displayName, + client = sharedHttpClient ) } } fun createClientWithUrl(provider: AiProvider, apiKey: String, baseUrl: String): AiClient { val displayName = provider.displayName - return GenericOpenAiClient(apiKey, baseUrl.trimEnd('/'), "", displayName) + return GenericOpenAiClient(apiKey, baseUrl.trimEnd('/'), "", displayName, sharedHttpClient) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt index 229f1d3140..37417d1b0e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/AiProvider.kt @@ -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 = false, 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 { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index cee7e23a45..27a4cce85d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -9,7 +9,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit /** * Gemini provider implementation. @@ -19,21 +18,13 @@ import java.util.concurrent.TimeUnit * SDK. This keeps generation in lockstep with whatever models the API actually serves, * so any model the user selects works as long as their key supports it. */ -class GeminiAiClient(private val apiKey: String) : AiClient { +class GeminiAiClient(private val apiKey: String, private val httpClient: OkHttpClient) : AiClient { companion object { private const val DEFAULT_GEMINI_MODEL = "gemini-3.1-flash-lite" private const val BASE_URL = "https://generativelanguage.googleapis.com/v1beta" - - } - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - private val json = Json { ignoreUnknownKeys = true isLenient = true @@ -165,44 +156,6 @@ class GeminiAiClient(private val apiKey: String) : AiClient { } } - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_GEMINI_MODEL } - try { - val requestBody = GenerateRequest( - contents = listOf(Content(role = "user", parts = listOf(Part(prompt)))), - systemInstruction = systemPrompt - .takeIf { it.isNotBlank() } - ?.let { Content(parts = listOf(Part(it))) }, - generationConfig = GenerationConfig(temperature = 0.0) - ) - val jsonBody = json.encodeToString(GenerateRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/models/$resolvedModel:countTokens") - .addHeader("x-goog-api-key", apiKey) - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext (prompt.length / 4) + (systemPrompt.length / 4) - } - val totalTokens = Regex(""""totalTokens"\s*:\s*(\d+)""") - .find(response.body.string()) - ?.groupValues - ?.getOrNull(1) - ?.toIntOrNull() - totalTokens ?: ((prompt.length / 4) + (systemPrompt.length / 4)) - } - } catch (e: Exception) { - (prompt.length / 4) + (systemPrompt.length / 4) - } - } - } - override suspend fun getAvailableModels(apiKey: String): List { return withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index fd0fb1c1d8..609c9b5685 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -18,7 +18,12 @@ class GenericOpenAiClient( private val apiKey: String, private val baseUrl: String, private val defaultModelId: String, - private val providerName: String = "OpenAI" + private val providerName: String = "OpenAI", + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() ) : AiClient { @Serializable @@ -47,12 +52,6 @@ class GenericOpenAiClient( @Serializable private data class ModelsResponse(val data: List) - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - private val json = Json { ignoreUnknownKeys = true isLenient = true @@ -132,11 +131,6 @@ class GenericOpenAiClient( } } - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Estimation for generic providers - return (systemPrompt.length + prompt.length) / 4 - } - override suspend fun getAvailableModels(apiKey: String): List { return withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiUsageBackupHandler.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiUsageBackupHandler.kt index 6805e692fd..7fbf6792c1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiUsageBackupHandler.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/module/AiUsageBackupHandler.kt @@ -34,7 +34,7 @@ class AiUsageBackupHandler @Inject constructor( // We additive restore AI logs or clear? Usually for logs we additive restore // but BackupModuleHandler documentation says "Clear existing data and restore". - aiUsageDao.clearAll() + aiUsageDao.clearUsage() if (logs.isNotEmpty()) { aiUsageDao.insertAll(logs) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt index 28a7503f34..705c3f2eec 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/AiUsageDao.kt @@ -33,7 +33,4 @@ interface AiUsageDao { @Query("DELETE FROM ai_usage") suspend fun clearUsage() - - @Query("DELETE FROM ai_usage") - suspend fun clearAll() } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 8a141e350e..05a05deb08 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -1631,20 +1631,8 @@ interface MusicDao { suspend fun deleteOrphanedArtists() // --- Favorite Operations --- - @Query("UPDATE songs SET is_favorite = :isFavorite WHERE id = :songId") - suspend fun setFavoriteStatus(songId: Long, isFavorite: Boolean) - - @Query("SELECT is_favorite FROM songs WHERE id = :songId") - suspend fun getFavoriteStatus(songId: Long): Boolean? - - // Transaction to toggle favorite status - @Transaction - suspend fun toggleFavoriteStatus(songId: Long): Boolean { - val currentStatus = getFavoriteStatus(songId) ?: false // Default to false if not found (should not happen for existing song) - val newStatus = !currentStatus - setFavoriteStatus(songId, newStatus) - return newStatus - } + // Note: Favorite status is managed via FavoritesDao. The songs.is_favorite column + // is kept in sync by SQL triggers on the favorites table. @Query(""" UPDATE songs diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt index 19f948b792..744f61cf0b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt @@ -217,7 +217,7 @@ fun List.toSongs(): List { // filePath y parentDirectoryPath se poblarán desde MediaStore en el SyncWorker. fun Song.toEntity(filePathFromMediaStore: String, parentDirFromMediaStore: String): SongEntity { return SongEntity( - id = this.id.toLong(), + id = this.id.toLongOrNull() ?: throw IllegalArgumentException("Cannot convert Song.id '${this.id}' to Long"), title = this.title, artistName = this.artist, artistId = this.artistId, @@ -256,7 +256,7 @@ data class SongSummary( // (menos probable que se use si la entidad siempre requiere los paths) fun Song.toEntityWithoutPaths(): SongEntity { return SongEntity( - id = this.id.toLong(), + id = this.id.toLongOrNull() ?: throw IllegalArgumentException("Cannot convert Song.id '${this.id}' to Long"), title = this.title, artistName = this.artist, artistId = this.artistId, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt index e1f7c41eb6..41d1f5435d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt @@ -769,10 +769,8 @@ class SongMetadataEditor( Timber.tag(TAG) .w("TAGLIB: Skipping file modification for high-resolution FLAC (${flacResult.sampleRate}Hz, ${flacResult.bitsPerSample}bit)") Timber.tag(TAG) - .w("TAGLIB: High-res FLAC files may not work correctly with TagLib. Will update MediaStore only.") - // Return true to indicate we should proceed with MediaStore-only update - // The calling code will still update MediaStore and local DB - return true + .w("TAGLIB: High-res FLAC files may not work correctly with TagLib. Falling back to JAudioTagger.") + return false } else -> { /* Continue with normal processing */ } } @@ -963,6 +961,17 @@ class SongMetadataEditor( } audioFile.commit() + + // Verify the write succeeded for FLAC files which can be problematic + if (targetFile.extension.equals("flac", ignoreCase = true)) { + val verifyFile = AudioFileIO.read(targetFile) + val verifyTag = verifyFile?.tag + if (verifyTag == null || verifyTag.getFirst(FieldKey.TITLE) != newTitle) { + Timber.tag(TAG).w("JAUDIOTAGGER: FLAC verification failed - tag not persisted, falling back to TagLib") + return false + } + } + Timber.tag(TAG).d("JAUDIOTAGGER: SUCCESS - Updated file metadata: $filePath") true } catch (e: Exception) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt b/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt index 2fda86c0b0..4e5f478ac7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt @@ -61,7 +61,7 @@ class MediaStorePagingSource( val songs = fetchSongDetails(idsToLoad) // Sort songs to match the order of idsToLoad (because "IN" query doesn't guarantee order) - val songsMap = songs.associateBy { it.id.toLong() } + val songsMap = songs.associateBy { it.id.toLongOrNull() ?: -1L } val orderedSongs = idsToLoad.mapNotNull { songsMap[it] } val nextKey = if (end < filteredIds.size) pageIndex + 1 else null diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt index 964875d040..32339a44cd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AiPreferencesRepository.kt @@ -152,7 +152,7 @@ class AiPreferencesRepository @Inject constructor( dataStore.data.map { preferences -> preferences[Keys.AI_TOP_K] ?: 64 } val aiMaxTokens: Flow = - dataStore.data.map { preferences -> preferences[Keys.AI_MAX_TOKENS] ?: 4096 } + dataStore.data.map { preferences -> preferences[Keys.AI_MAX_TOKENS] ?: 8192 } val aiPresencePenalty: Flow = dataStore.data.map { preferences -> preferences[Keys.AI_PRESENCE_PENALTY] ?: 0.0f } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt index 5630700f90..e926bda366 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt @@ -12,11 +12,14 @@ enum class AppLanguage(val tag: String, @StringRes val labelRes: Int) { FRENCH("fr", R.string.settings_language_french), INDONESIAN("in", R.string.settings_language_indonesian), ITALIAN("it", R.string.settings_language_italian), + JAPANESE("ja", R.string.settings_language_japanese), KOREAN("ko", R.string.settings_language_korean), NORWEGIAN_BOKMAL("nb", R.string.settings_language_norwegian_bokmal), RUSSIAN("ru", R.string.settings_language_russian), SIMPLIFIED_CHINESE("zh-CN", R.string.settings_language_chinese), - TURKISH("tr", R.string.settings_language_turkish); + TRADITIONAL_CHINESE("zh-TW", R.string.settings_language_traditional_chinese), + TURKISH("tr", R.string.settings_language_turkish), + VIETNAMESE("vi", R.string.settings_language_vietnamese); companion object { val supportedLanguageTags: Set = values().map { it.tag }.toSet() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86f..d353c6c118 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -507,32 +507,11 @@ class UserPreferencesRepository @Inject constructor( dataStore.edit { it[PreferencesKeys.GLOBAL_TRANSITION_SETTINGS] = json.encodeToString(settings) } } - // ─── Favorites ──────────────────────────────────────────────────────────── + // ─── Favorites (Legacy — only used for one-time migration) ───────────────── val favoriteSongIdsFlow: Flow> = pref { it[PreferencesKeys.FAVORITE_SONG_IDS] ?: emptySet() } - /** - * Adds or removes [songId] from favorites depending on [isFavorite]. - * Prefer this over [toggleFavoriteSong] when the desired state is known. - */ - suspend fun setFavoriteSong(songId: String, isFavorite: Boolean) { - dataStore.edit { preferences -> - val current = preferences[PreferencesKeys.FAVORITE_SONG_IDS] ?: emptySet() - preferences[PreferencesKeys.FAVORITE_SONG_IDS] = - if (isFavorite) current + songId else current - songId - } - } - - /** Toggles [songId] in the favorites set. */ - suspend fun toggleFavoriteSong(songId: String) { - dataStore.edit { preferences -> - val current = preferences[PreferencesKeys.FAVORITE_SONG_IDS] ?: emptySet() - preferences[PreferencesKeys.FAVORITE_SONG_IDS] = - if (songId in current) current - songId else current + songId - } - } - suspend fun clearFavoriteSongIds() { dataStore.edit { it[PreferencesKeys.FAVORITE_SONG_IDS] = emptySet() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt index 05e357d05c..948fdfc62a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt @@ -527,6 +527,15 @@ class LyricsRepositoryImpl @Inject constructor( }) } } + // Romanized search strategy for non-Latin artists + if (MultiLangRomanizer.isScriptThatNeedsRomanization(cleanArtist)) { + val romanArtist = romanizeForMatch(cleanArtist) + if (romanArtist != cleanArtist) { + add(RemoteSearchStrategy("romanized_artist") { + lrcLibApiService.searchLyrics(trackName = cleanTitle, artistName = romanArtist) + }) + } + } // Smart title cleanup strategy (removes leading digits/spaces and truncates at -, (, )) val smartTitle = cleanTitleSmart(cleanTitle) @@ -581,14 +590,19 @@ class LyricsRepositoryImpl @Inject constructor( // Save to database try { - lyricsDao.insert( - com.theveloper.pixelplay.data.database.LyricsEntity( - songId = song.id.toLong(), - content = rawLyrics, - isSynced = !bestMatch.syncedLyrics.isNullOrBlank(), - source = "remote" + val songIdLong = song.id.toLongOrNull() + if (songIdLong == null) { + Log.w(TAG, "Cannot persist lyrics to DB: non-numeric Song.id '${song.id}'") + } else { + lyricsDao.insert( + com.theveloper.pixelplay.data.database.LyricsEntity( + songId = songIdLong, + content = rawLyrics, + isSynced = !bestMatch.syncedLyrics.isNullOrBlank(), + source = "remote" + ) ) - ) + } } catch (e: NumberFormatException) { Log.w(TAG, "Skipping database save for non-numeric song ID: ${song.id} (possible streaming or external source). Lyrics will be cached in JSON.") } @@ -1014,8 +1028,8 @@ class LyricsRepositoryImpl @Inject constructor( } } - val cleanArtist = song.displayArtist.replace(Regex("[^a-zA-Z0-9]"), "_") - val cleanTitle = song.title.replace(Regex("[^a-zA-Z0-9]"), "_") + val cleanArtist = song.displayArtist.replace(Regex("[^\\p{L}\\p{N}]"), "_") + val cleanTitle = song.title.replace(Regex("[^\\p{L}\\p{N}]"), "_") for (extension in LyricsImportSecurity.supportedFileExtensions()) { val alternativeLyricsFile = File(directory, "${cleanArtist}_${cleanTitle}.$extension") @@ -1056,7 +1070,14 @@ class LyricsRepositoryImpl @Inject constructor( val lyricsData = LyricsData( plainLyrics = lyrics.plain?.joinToString("\n"), - syncedLyrics = lyrics.synced?.joinToString("\n") { "[${formatTimestamp(it.time)}]${it.line}" }, + syncedLyrics = lyrics.synced?.joinToString("\n") { line -> + buildString { + append("[${formatTimestamp(line.time)}]${line.line}") + if (!line.translation.isNullOrBlank()) { + append("\n[${formatTimestamp(line.time)}]${line.translation}") + } + } + }, wordByWordLyrics = wordByWordLyrics ) @@ -1247,7 +1268,12 @@ class LyricsRepositoryImpl @Inject constructor( toWordByWordLrc(syncedLyrics) } else { syncedLyrics.joinToString("\n") { line -> - "[${formatTimestamp(line.time)}]${line.line}" + buildString { + append("[${formatTimestamp(line.time)}]${line.line}") + if (!line.translation.isNullOrBlank()) { + append("\n[${formatTimestamp(line.time)}]${line.translation}") + } + } } } } @@ -1293,14 +1319,19 @@ class LyricsRepositoryImpl @Inject constructor( val rawLyricsToSave = best.rawLyrics try { - lyricsDao.insert( - com.theveloper.pixelplay.data.database.LyricsEntity( - songId = song.id.toLong(), - content = rawLyricsToSave, - isSynced = !best.lyrics.synced.isNullOrEmpty(), - source = "remote" - ) - ) + val songIdLong = song.id.toLongOrNull() + if (songIdLong == null) { + Log.w(TAG, "Cannot persist lyrics to DB: non-numeric Song.id '${song.id}'") + } else { + lyricsDao.insert( + com.theveloper.pixelplay.data.database.LyricsEntity( + songId = songIdLong, + content = rawLyricsToSave, + isSynced = !best.lyrics.synced.isNullOrEmpty(), + source = "remote" + ) + ) + } } catch (e: NumberFormatException) { Log.w(TAG, "Skipping DB update for non-numeric ID: ${song.id}") } @@ -1336,9 +1367,14 @@ class LyricsRepositoryImpl @Inject constructor( } try { + val songIdLong = song.id.toLongOrNull() + if (songIdLong == null) { + Log.w(TAG, "Cannot persist lyrics to DB: non-numeric Song.id '${song.id}'") + return@withContext Result.failure(LyricsException("Non-numeric Song.id")) + } lyricsDao.insert( com.theveloper.pixelplay.data.database.LyricsEntity( - songId = song.id.toLong(), + songId = songIdLong, content = rawLyricsToSave, isSynced = !parsedLyrics.synced.isNullOrEmpty(), source = "remote" @@ -1352,7 +1388,7 @@ class LyricsRepositoryImpl @Inject constructor( saveLocalLyricsJson(song, parsedLyrics) LogUtils.d(this@LyricsRepositoryImpl, "Fetched and cached remote lyrics (exact match) for: ${song.title}") - Result.success(Pair(parsedLyrics, rawLyricsToSave)) + return@withContext Result.success(Pair(parsedLyrics, rawLyricsToSave)) } else { LogUtils.d(this@LyricsRepositoryImpl, "No lyrics found remotely for: ${song.title}") Result.failure(NoLyricsFoundException()) @@ -1388,6 +1424,24 @@ class LyricsRepositoryImpl @Inject constructor( lrcLibApiService.searchLyrics(trackName = cleanTitle, artistName = cleanArtist) }) + // Romanized search strategy for non-Latin titles + if (MultiLangRomanizer.isScriptThatNeedsRomanization(cleanTitle)) { + val romanTitle = romanizeForMatch(cleanTitle) + if (romanTitle != cleanTitle) { + add(RemoteSearchStrategy("romanized_track") { + lrcLibApiService.searchLyrics(trackName = romanTitle, artistName = cleanArtist) + }) + } + } + if (MultiLangRomanizer.isScriptThatNeedsRomanization(cleanArtist)) { + val romanArtist = romanizeForMatch(cleanArtist) + if (romanArtist != cleanArtist) { + add(RemoteSearchStrategy("romanized_artist") { + lrcLibApiService.searchLyrics(trackName = cleanTitle, artistName = romanArtist) + }) + } + } + // Smart title cleanup strategy val smartTitle = cleanTitleSmart(cleanTitle) if (smartTitle != cleanTitle && smartTitle.isNotBlank()) { @@ -1535,7 +1589,9 @@ class LyricsRepositoryImpl @Inject constructor( // Also remove JSON cache try { val file = File(context.filesDir, "lyrics/${songId}.json") - if (file.exists()) file.delete() + if (file.exists() && !file.delete()) { + Log.w(TAG, "Failed to delete JSON cache for songId: $songId") + } } catch (e: Exception) { Log.w(TAG, "Error deleting JSON cache: ${e.message}") } @@ -1613,8 +1669,8 @@ class LyricsRepositoryImpl @Inject constructor( // Strategy 2: Artist - Title if (foundFile == null) { - val cleanArtist = song.displayArtist.replace(Regex("[^a-zA-Z0-9]"), "_") - val cleanTitle = song.title.replace(Regex("[^a-zA-Z0-9]"), "_") + val cleanArtist = song.displayArtist.replace(Regex("[^\\p{L}\\p{N}]"), "_") + val cleanTitle = song.title.replace(Regex("[^\\p{L}\\p{N}]"), "_") for (extension in LyricsImportSecurity.supportedFileExtensions()) { val altMatch = File(directory, "${cleanArtist}_${cleanTitle}.$extension") if (altMatch.exists() && altMatch.canRead()) { @@ -1628,14 +1684,19 @@ class LyricsRepositoryImpl @Inject constructor( val validated = readValidatedLocalLyrics(foundFile) if (validated != null) { try { - lyricsDao.insert( - com.theveloper.pixelplay.data.database.LyricsEntity( - songId = song.id.toLong(), - content = validated.sanitizedContent, - isSynced = validated.parsedLyrics.synced?.isNotEmpty() == true, - source = "local_file" - ) - ) + val songIdLong = song.id.toLongOrNull() + if (songIdLong == null) { + Log.w(TAG, "Cannot persist lyrics to DB: non-numeric Song.id '${song.id}'") + } else { + lyricsDao.insert( + com.theveloper.pixelplay.data.database.LyricsEntity( + songId = songIdLong, + content = validated.sanitizedContent, + isSynced = validated.parsedLyrics.synced?.isNotEmpty() == true, + source = "local_file" + ) + ) + } updatedCount.incrementAndGet() LogUtils.d(this@LyricsRepositoryImpl, "Auto-assigned lyrics from ${foundFile.name}") } catch (e: Exception) { @@ -1710,6 +1771,8 @@ class LyricsRepositoryImpl @Inject constructor( MultiLangRomanizer.isJapanese(text) -> MultiLangRomanizer.romanizeJapanese(text) ?: text MultiLangRomanizer.isChinese(text) -> MultiLangRomanizer.romanizeChinese(text) ?: text MultiLangRomanizer.isKorean(text) -> MultiLangRomanizer.romanizeKorean(text) + MultiLangRomanizer.isCyrillic(text) -> MultiLangRomanizer.romanizeCyrillic(text) ?: text + MultiLangRomanizer.isVietnamese(text) -> MultiLangRomanizer.romanizeVietnamese(text) else -> text } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt index 36d9081b99..d7bcb635f5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepository.kt @@ -77,12 +77,6 @@ interface MusicRepository { storageFilter: com.theveloper.pixelplay.data.model.StorageFilter = com.theveloper.pixelplay.data.model.StorageFilter.ALL ): Flow - /** - * Returns the count of songs in the library. - * @return Flow emitting the current song count. - */ - fun getSongCountFlow(): Flow - /** * Returns the count of cloud songs in the library. */ diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt index 5f026af8d9..11a16bd1e6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt @@ -310,10 +310,6 @@ class MusicRepositoryImpl @Inject constructor( return songRepository.getFavoriteSongCountFlow(storageFilter) } - override fun getSongCountFlow(): Flow { - return musicDao.getSongCount().distinctUntilChanged() - } - override fun getCloudSongCountFlow(): Flow { return musicDao.getCloudSongCount().distinctUntilChanged() } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index adab461ca8..6cb5fda92f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -46,7 +46,6 @@ import com.theveloper.pixelplay.MainActivity import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.diagnostics.PerformanceMetrics import com.theveloper.pixelplay.data.model.PlayerInfo -import com.theveloper.pixelplay.data.model.PlaybackQueueItemSnapshot import com.theveloper.pixelplay.data.model.PlaybackQueueSnapshot import com.theveloper.pixelplay.data.preferences.EqualizerPreferencesRepository import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository @@ -79,6 +78,12 @@ import com.theveloper.pixelplay.data.preferences.ThemePreference import com.theveloper.pixelplay.data.service.auto.AutoMediaBrowseTree import com.theveloper.pixelplay.data.service.wear.buildWearThemePalette import com.theveloper.pixelplay.data.service.wear.WearStatePublisher +import com.theveloper.pixelplay.data.service.playback.PlaybackSnapshotManager +import com.theveloper.pixelplay.data.service.playback.HeadsetReconnectMonitor +import com.theveloper.pixelplay.data.service.playback.NavidromePlaybackReporter +import com.theveloper.pixelplay.data.service.playback.SleepTimerController +import com.theveloper.pixelplay.data.service.playback.CountedPlayController +import com.theveloper.pixelplay.data.service.widget.WidgetArtworkResolver import com.theveloper.pixelplay.presentation.viewmodel.ColorSchemePair import com.theveloper.pixelplay.shared.WearIntents import com.theveloper.pixelplay.utils.ArtworkTransportSanitizer @@ -86,55 +91,9 @@ import com.theveloper.pixelplay.utils.MediaItemBuilder import com.theveloper.pixelplay.data.navidrome.NavidromeRepository import com.theveloper.pixelplay.di.AppScope import com.theveloper.pixelplay.presentation.viewmodel.ListeningStatsTracker -import java.io.ByteArrayOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.atomic.AtomicInteger -import coil.imageLoader -import coil.request.CachePolicy -import coil.request.ImageRequest -import coil.size.Precision - import javax.inject.Inject import androidx.core.net.toUri -// Acciones personalizadas para compatibilidad con el widget existente - -suspend fun loadArtworkBytesViaCoil(context: Context, uri: Uri): ByteArray? { - val appContext = context.applicationContext - val request = ImageRequest.Builder(appContext) - .data(uri) - .size( - ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx, - ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx, - ) - .precision(Precision.INEXACT) - .allowHardware(false) - .memoryCachePolicy(CachePolicy.ENABLED) - .networkCachePolicy(CachePolicy.ENABLED) - .build() - - return runCatching { - val drawable = appContext.imageLoader.execute(request).drawable ?: return@runCatching null - val fallbackSizePx = ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx - val bitmap = drawable.toBitmap( - width = drawable.intrinsicWidth.takeIf { it > 0 } ?: fallbackSizePx, - height = drawable.intrinsicHeight.takeIf { it > 0 } ?: fallbackSizePx, - config = Bitmap.Config.ARGB_8888, - ) - val encodedBytes = ByteArrayOutputStream().use { output -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 92, output) - output.toByteArray() - } - ArtworkTransportSanitizer.sanitizeEncodedBytes( - data = encodedBytes, - config = ArtworkTransportSanitizer.WIDGET_CONFIG, - ) - }.getOrElse { error -> - Timber.tag("MusicService_PixelPlay").w(error, "Artwork read failed via Coil for uri=%s", uri) - null - } -} @UnstableApi @@ -192,16 +151,9 @@ class MusicService : MediaLibraryService() { private var persistentShuffleEnabled = false // Holds the previous main-thread UncaughtExceptionHandler so we can restore it in onDestroy. private var previousMainThreadExceptionHandler: Thread.UncaughtExceptionHandler? = null - // --- Counted Play State --- - private var countedPlayActive = false - private var countedPlayTarget = 0 - private var countedPlayCount = 0 - private var countedOriginalId: String? = null - private var countedPlayListener: Player.Listener? = null private val alarmManager by lazy { getSystemService(Context.ALARM_SERVICE) as AlarmManager } - private var endOfTrackTimerSongId: String? = null // Cast remote-session synchronization, extracted to a standalone coordinator. // Lazily built so the Hilt-injected listeningStatsTracker is ready before first use. private val castSyncCoordinator by lazy { @@ -223,16 +175,54 @@ class MusicService : MediaLibraryService() { resolveCurrentMediaIdForWear = { resolveCurrentMediaIdForWear() }, ) } - private var playbackSnapshotPersistJob: Job? = null - private var playbackSnapshotUnloadWriteJob: Job? = null - private var isRestoringPlaybackSnapshot = false - private var isPlaybackUnloadInProgress = false + private val widgetArtworkResolver by lazy { + WidgetArtworkResolver( + context = applicationContext, + engine = engine, + musicRepository = musicRepository, + ) + } + private val snapshotManager by lazy { + PlaybackSnapshotManager( + context = this, + engine = engine, + userPreferencesRepository = userPreferencesRepository, + scope = serviceScope, + ).also { + it.setShuffleStateProvider { isManualShuffleEnabled } + } + } + private val sleepTimerController by lazy { + SleepTimerController( + context = this, + alarmManager = alarmManager, + actionSleepTimerExpired = ACTION_SLEEP_TIMER_EXPIRED, + currentPlayer = { mediaSession?.player }, + currentMediaId = { mediaSession?.player?.currentMediaItem?.mediaId }, + ) + } + private val headsetReconnectMonitor by lazy { + HeadsetReconnectMonitor( + audioManager = audioManager, + engine = engine, + isResumeEnabled = { resumeOnHeadsetReconnectEnabled }, + currentPlayer = { engine.masterPlayer }, + ) + } + private val navidromeReporter by lazy { + NavidromePlaybackReporter( + navidromeRepository = navidromeRepository, + engine = engine, + serviceScope = serviceScope, + appScope = appScope, + ) + } + private val countedPlayController by lazy { + CountedPlayController(engine = engine) + } private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager } - private var headsetReconnectCallback: AudioDeviceCallback? = null - private var shouldResumeAfterHeadsetReconnect = false - private var lastNoisyPauseRealtimeMs = 0L private var resumeOnHeadsetReconnectEnabled = false private var pauseOnVolumeZeroEnabled = false private var temporaryForegroundStartedInOnCreate = false @@ -261,10 +251,6 @@ class MusicService : MediaLibraryService() { const val ACTION_SLEEP_TIMER_EXPIRED = "com.theveloper.pixelplay.ACTION_SLEEP_TIMER_EXPIRED" const val EXTRA_FORCE_FOREGROUND_ON_START = "com.theveloper.pixelplay.extra.FORCE_FOREGROUND_ON_START" - // Queue/index/flags snapshot is only used for restore on process death. A full-queue - // JSON+DataStore rewrite on every Media3 event (track transition fires 3-4 listeners - // within ~200ms) is unnecessary work. 1500ms coalesces those without harming restore. - private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 250L private const val DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS = 1_000L private const val PAUSED_RESTORE_PREPARE_QUEUE_LIMIT = 50 @@ -292,8 +278,6 @@ class MusicService : MediaLibraryService() { private const val AUTO_CONTEXT_ALBUM = "album" private const val AUTO_CONTEXT_ARTIST = "artist" private const val AUTO_CONTEXT_PLAYLIST = "playlist" - private const val DEFAULT_STREAM_BUFFER_SIZE = 8 * 1024 - private const val WIDGET_ART_FAILURE_RETRY_MS = 30_000L private const val HEADSET_RECONNECT_RESUME_WINDOW_MS = 15_000L fun markPendingMediaButtonForegroundStart() { @@ -447,7 +431,7 @@ class MusicService : MediaLibraryService() { controller.initialize() serviceScope.launch { delay(DEFERRED_SERVICE_STARTUP_WORK_DELAY_MS) - if (!isPlaybackUnloadInProgress && mediaSession != null) { + if (!snapshotManager.isUnloadInProgress && mediaSession != null) { castSyncCoordinator.start() } } @@ -455,8 +439,8 @@ class MusicService : MediaLibraryService() { serviceScope.launch { musicRepository.telegramRepository.downloadCompleted.collect { - if (isCurrentWidgetArtworkBackedByTelegram()) { - invalidateCachedWidgetArtwork() + if (widgetArtworkResolver.isCurrentWidgetArtworkBackedByTelegram()) { + widgetArtworkResolver.invalidateCachedWidgetArtwork() widgetUpdateManager.requestWithFollowUp() } } @@ -953,82 +937,17 @@ class MusicService : MediaLibraryService() { return hasWearHints } - private fun createSleepTimerPendingIntent(): PendingIntent { - val intent = Intent(this, SleepTimerReceiver::class.java).apply { - action = ACTION_SLEEP_TIMER_EXPIRED - setPackage(packageName) - } - return PendingIntent.getBroadcast( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } + private fun createSleepTimerPendingIntent() = sleepTimerController.createSleepTimerPendingIntent() - private fun cancelDurationSleepTimerInternal() { - alarmManager.cancel(createSleepTimerPendingIntent()) - } + private fun cancelDurationSleepTimerInternal() = sleepTimerController.cancelDurationSleepTimerInternal() - private fun setDurationSleepTimer(minutes: Int) { - if (minutes <= 0) { - cancelSleepTimers() - return - } - endOfTrackTimerSongId = null - val triggerAtMillis = System.currentTimeMillis() + (minutes * 60_000L) - val pendingIntent = createSleepTimerPendingIntent() + private fun setDurationSleepTimer(minutes: Int) = sleepTimerController.setDurationSleepTimer(minutes) - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (alarmManager.canScheduleExactAlarms()) { - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, - triggerAtMillis, - pendingIntent, - ) - } else { - alarmManager.setAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, - triggerAtMillis, - pendingIntent, - ) - } - } else - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, - triggerAtMillis, - pendingIntent, - ) - Timber.tag(TAG).d("Sleep timer set from Wear for %d minutes", minutes) - } catch (e: SecurityException) { - Timber.tag(TAG).w(e, "Exact alarm denied; using inexact sleep timer") - alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) - } - } + private fun setEndOfTrackSleepTimer(enabled: Boolean) = sleepTimerController.setEndOfTrackSleepTimer(enabled) - private fun setEndOfTrackSleepTimer(enabled: Boolean) { - if (!enabled) { - endOfTrackTimerSongId = null - Timber.tag(TAG).d("End-of-track timer disabled from Wear") - return - } - cancelDurationSleepTimerInternal() - val currentSongId = mediaSession?.player?.currentMediaItem?.mediaId - if (currentSongId.isNullOrBlank()) { - endOfTrackTimerSongId = null - Timber.tag(TAG).d("End-of-track timer ignored: no active song") - return - } - endOfTrackTimerSongId = currentSongId - Timber.tag(TAG).d("End-of-track timer set from Wear for mediaId=%s", currentSongId) - } + private fun cancelSleepTimers() = sleepTimerController.cancelSleepTimers() - private fun cancelSleepTimers() { - cancelDurationSleepTimerInternal() - endOfTrackTimerSongId = null - Timber.tag(TAG).d("Sleep timers cancelled from Wear") - } + private val endOfTrackTimerSongId: String? get() = sleepTimerController.endOfTrackTimerSongId private fun startTemporaryForegroundForCommand() { val notification = NotificationCompat.Builder( @@ -1187,64 +1106,16 @@ class MusicService : MediaLibraryService() { return startCommandResult } - private fun getNavidromeId(mediaItem: MediaItem?): String? { - if (mediaItem == null) return null - return mediaItem.mediaMetadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_NAVIDROME_ID) - ?: mediaItem.mediaId.let { if (it.startsWith("navidrome_")) it.substringAfter("navidrome_") else null } - ?: mediaItem.mediaMetadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI)?.let { - if (it.startsWith("navidrome://")) it.substringAfter("navidrome://") else null - } - } + private fun getNavidromeId(mediaItem: MediaItem?) = navidromeReporter.getNavidromeId(mediaItem) - private fun isNavidromeMediaItem(mediaItem: MediaItem?): Boolean { - return getNavidromeId(mediaItem) != null - } - - private fun reportNavidromePlayback(state: String, mediaItem: MediaItem? = engine.masterPlayer.currentMediaItem) { - val player = engine.masterPlayer - // Ensure we capture player state on main thread to avoid IllegalStateException - val targetItem = mediaItem ?: return - val navidromeId = getNavidromeId(targetItem) ?: return - - // If reporting for current item, use player position. - // If reporting "stopped" for a transition, use the item's duration as final position. - val positionMs = if (targetItem === player.currentMediaItem) { - player.currentPosition - } else { - targetItem.mediaMetadata.extras?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) ?: 0L - } - val playbackRate = player.playbackParameters.speed - - // Use appScope for the network call so it survives if serviceScope is cancelled - appScope.launch(Dispatchers.IO) { - navidromeRepository.reportPlayback( - navidromeId = navidromeId, - positionMs = positionMs, - state = state, - playbackRate = playbackRate - ) - } - } + private fun isNavidromeMediaItem(mediaItem: MediaItem?) = navidromeReporter.isNavidromeMediaItem(mediaItem) - private var navidromePlaybackReportJob: Job? = null + private fun reportNavidromePlayback(state: String, mediaItem: MediaItem? = engine.masterPlayer.currentMediaItem) = + navidromeReporter.reportPlayback(state, mediaItem) - private fun startNavidromePlaybackReporting() { - navidromePlaybackReportJob?.cancel() - navidromePlaybackReportJob = serviceScope.launch { - while (true) { - delay(30_000) // Report every 30 seconds - val player = engine.masterPlayer - if (player.isPlaying && isNavidromeMediaItem(player.currentMediaItem)) { - reportNavidromePlayback("playing") - } - } - } - } + private fun startNavidromePlaybackReporting() = navidromeReporter.startReporting() - private fun stopNavidromePlaybackReporting() { - navidromePlaybackReportJob?.cancel() - navidromePlaybackReportJob = null - } + private fun stopNavidromePlaybackReporting() = navidromeReporter.stopReporting() private val playerListener = object : Player.Listener { override fun onVolumeChanged(volume: Float) { @@ -1294,9 +1165,7 @@ class MusicService : MediaLibraryService() { playWhenReady -> clearHeadsetReconnectResume() !resumeOnHeadsetReconnectEnabled -> clearHeadsetReconnectResume() reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY -> { - shouldResumeAfterHeadsetReconnect = true - lastNoisyPauseRealtimeMs = SystemClock.elapsedRealtime() - Timber.tag(TAG).d("Marked playback for headset reconnect resume") + headsetReconnectMonitor.markNoisyPause() } else -> clearHeadsetReconnectResume() } @@ -1322,7 +1191,7 @@ class MusicService : MediaLibraryService() { } } - endOfTrackTimerSongId = null + cancelSleepTimers() reportNavidromePlayback("stopped") stopNavidromePlaybackReporting() } else { @@ -1394,7 +1263,7 @@ class MusicService : MediaLibraryService() { stopNavidromePlaybackReporting() } - val eotTargetSongId = endOfTrackTimerSongId + val eotTargetSongId = sleepTimerController.endOfTrackTimerSongId if (!eotTargetSongId.isNullOrBlank()) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { val previousSongId = engine.masterPlayer.run { @@ -1405,13 +1274,13 @@ class MusicService : MediaLibraryService() { } } if (previousSongId == eotTargetSongId) { - endOfTrackTimerSongId = null + cancelSleepTimers() engine.masterPlayer.seekTo(0L) engine.masterPlayer.pause() Timber.tag(TAG).d("Paused playback at end of track from Wear timer") } } else if (mediaItem?.mediaId != eotTargetSongId) { - endOfTrackTimerSongId = null + cancelSleepTimers() Timber.tag(TAG).d("Cleared end-of-track timer after manual track change") } } @@ -1495,7 +1364,6 @@ class MusicService : MediaLibraryService() { listeningStatsTracker.finalizeCurrentSession(forceSynchronousPersistence = true) reportNavidromePlayback("stopped") stopNavidromePlaybackReporting() - playbackSnapshotPersistJob?.cancel() mediaSessionButtonRefreshJob?.cancel() followUpMediaSessionUiRefreshJob?.cancel() widgetUpdateManager.cancel() @@ -1537,32 +1405,18 @@ class MusicService : MediaLibraryService() { // (TRIM_MEMORY_BACKGROUND = 40, TRIM_MEMORY_COMPLETE = 80, etc.). if (level >= 10 /* TRIM_MEMORY_RUNNING_LOW */) { Timber.tag(TAG).d("onTrimMemory(level=%d): releasing widget bitmap caches", level) - invalidateCachedWidgetArtwork() + widgetArtworkResolver.invalidateCachedWidgetArtwork() // Drop the stale PlayerInfo copy so its embedded ByteArray is GC-eligible. // The next widget update cycle will rebuild it from scratch. widgetUpdateManager.clearCachedState() } } - private fun registerHeadsetReconnectMonitor() { - val callback = object : AudioDeviceCallback() { - override fun onAudioDevicesAdded(addedDevices: Array) { - if (!addedDevices.any(::isReconnectableHeadsetOutput)) return - maybeResumeAfterHeadsetReconnect() - } - } + private fun registerHeadsetReconnectMonitor() = headsetReconnectMonitor.register() - audioManager.registerAudioDeviceCallback(callback, null) - headsetReconnectCallback = callback - } + private fun unregisterHeadsetReconnectMonitor() = headsetReconnectMonitor.unregister() - private fun unregisterHeadsetReconnectMonitor() { - headsetReconnectCallback?.let { callback -> - runCatching { audioManager.unregisterAudioDeviceCallback(callback) } - } - headsetReconnectCallback = null - clearHeadsetReconnectResume() - } + private fun clearHeadsetReconnectResume() = headsetReconnectMonitor.clearResume() private fun registerSystemVolumeObserver() { contentResolver.registerContentObserver( @@ -1576,154 +1430,8 @@ class MusicService : MediaLibraryService() { runCatching { contentResolver.unregisterContentObserver(systemVolumeObserver) } } - private fun maybeResumeAfterHeadsetReconnect() { - if (!resumeOnHeadsetReconnectEnabled || !shouldResumeAfterHeadsetReconnect) return - - val elapsedSinceNoisyPause = SystemClock.elapsedRealtime() - lastNoisyPauseRealtimeMs - if (elapsedSinceNoisyPause > HEADSET_RECONNECT_RESUME_WINDOW_MS) { - clearHeadsetReconnectResume() - return - } - - if (!hasReconnectableHeadsetOutput()) { - return - } - - val player = engine.masterPlayer - if ( - player.currentMediaItem == null || - player.playWhenReady || - player.playbackState == Player.STATE_IDLE || - player.playbackState == Player.STATE_ENDED - ) { - clearHeadsetReconnectResume() - return - } - - Timber.tag(TAG).d("Resuming playback after headset reconnect") - clearHeadsetReconnectResume() - player.play() - } - - private fun hasReconnectableHeadsetOutput(): Boolean { - return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - .any(::isReconnectableHeadsetOutput) - } - - private fun isReconnectableHeadsetOutput(device: AudioDeviceInfo): Boolean { - return when (device.type) { - AudioDeviceInfo.TYPE_WIRED_HEADSET, - AudioDeviceInfo.TYPE_WIRED_HEADPHONES, - AudioDeviceInfo.TYPE_USB_HEADSET, - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, - AudioDeviceInfo.TYPE_BLUETOOTH_SCO, - AudioDeviceInfo.TYPE_BLE_HEADSET, - AudioDeviceInfo.TYPE_BLE_SPEAKER -> true - else -> false - } - } - - private fun clearHeadsetReconnectResume() { - shouldResumeAfterHeadsetReconnect = false - lastNoisyPauseRealtimeMs = 0L - } - private fun schedulePlaybackSnapshotPersist(immediate: Boolean = false) { - if (isPlaybackUnloadInProgress) { - return - } - playbackSnapshotPersistJob?.cancel() - playbackSnapshotPersistJob = serviceScope.launch { - if (!immediate) { - delay(PLAYBACK_SNAPSHOT_DEBOUNCE_MS) - } - persistPlaybackSnapshot() - } - } - - private suspend fun persistPlaybackSnapshot(playWhenReadyOverride: Boolean? = null) { - if (isRestoringPlaybackSnapshot) return - val snapshot = capturePlaybackSnapshot(playWhenReadyOverride) - runCatching { - userPreferencesRepository.setPlaybackQueueSnapshot(snapshot) - }.onFailure { e -> - Timber.tag(TAG).w(e, "Failed to persist playback snapshot") - } - } - - private suspend fun capturePlaybackSnapshot(playWhenReadyOverride: Boolean? = null): PlaybackQueueSnapshot? = - withContext(Dispatchers.Main.immediate) { - capturePlaybackSnapshotFromPlayer(playWhenReadyOverride) - } - - private fun capturePlaybackSnapshotFromPlayer( - playWhenReadyOverride: Boolean? = null - ): PlaybackQueueSnapshot? { - val player = engine.masterPlayer - val mediaItemCount = player.mediaItemCount - if (mediaItemCount <= 0) { - return null - } - - val snapshotItems = ArrayList(mediaItemCount) - for (index in 0 until mediaItemCount) { - val mediaItem = player.getMediaItemAt(index) - val metadata = mediaItem.mediaMetadata - val uri = mediaItem.localConfiguration?.uri?.toString() - ?: metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) - - if (mediaItem.mediaId.isBlank() || uri.isNullOrBlank()) { - continue - } - - val durationMs = metadata.extras - ?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) - ?.takeIf { it > 0L } - - snapshotItems.add( - PlaybackQueueItemSnapshot( - mediaId = mediaItem.mediaId, - uri = uri, - title = metadata.title?.toString(), - artist = metadata.artist?.toString(), - albumTitle = metadata.albumTitle?.toString(), - artworkUri = resolveStoredArtworkUriString(metadata), - durationMs = durationMs, - ) - ) - } - - if (snapshotItems.isEmpty()) { - return null - } - - val currentMediaId = player.currentMediaItem?.mediaId - val indexFromMediaId = currentMediaId - ?.let { id -> snapshotItems.indexOfFirst { it.mediaId == id } } - ?.takeIf { it >= 0 } - - val safeCurrentIndex = when { - indexFromMediaId != null -> indexFromMediaId - player.currentMediaItemIndex in snapshotItems.indices -> player.currentMediaItemIndex - else -> 0 - } - - val safeRepeatMode = when (player.repeatMode) { - Player.REPEAT_MODE_OFF, - Player.REPEAT_MODE_ONE, - Player.REPEAT_MODE_ALL -> player.repeatMode - else -> Player.REPEAT_MODE_OFF - } - - return PlaybackQueueSnapshot( - items = snapshotItems, - currentMediaId = currentMediaId, - currentIndex = safeCurrentIndex, - currentPositionMs = player.currentPosition.coerceAtLeast(0L), - playWhenReady = playWhenReadyOverride ?: player.playWhenReady, - repeatMode = safeRepeatMode, - shuffleEnabled = isManualShuffleEnabled, - ) + snapshotManager.schedulePersist(immediate) } private suspend fun restorePlaybackQueueSnapshotIfNeeded() { @@ -1745,7 +1453,7 @@ class MusicService : MediaLibraryService() { }.getOrDefault(keepPlayingInBackground) val shouldRestorePlaying = snapshot.playWhenReady && allowBackgroundPlayback - val restoredItems = snapshot.items.mapNotNull(::buildMediaItemFromSnapshot) + val restoredItems = snapshot.items.mapNotNull { snapshotManager.buildMediaItemFromSnapshot(it) } if (restoredItems.isEmpty()) { userPreferencesRepository.setPlaybackQueueSnapshot(null) return @@ -1781,7 +1489,7 @@ class MusicService : MediaLibraryService() { else -> Player.REPEAT_MODE_OFF } - isRestoringPlaybackSnapshot = true + snapshotManager.beginRestore() try { player.setMediaItems( preparedItems, @@ -1800,7 +1508,7 @@ class MusicService : MediaLibraryService() { player.playWhenReady = false } } finally { - isRestoringPlaybackSnapshot = false + snapshotManager.endRestore() } } @@ -1810,44 +1518,7 @@ class MusicService : MediaLibraryService() { snapshot.currentIndex, shouldRestorePlaying ) - schedulePlaybackSnapshotPersist(immediate = true) - } - - private fun buildMediaItemFromSnapshot(snapshotItem: PlaybackQueueItemSnapshot): MediaItem? { - if (snapshotItem.mediaId.isBlank() || snapshotItem.uri.isBlank()) { - return null - } - - val metadataBuilder = MediaMetadata.Builder() - snapshotItem.title?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setTitle(it) } - snapshotItem.artist?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setArtist(it) } - snapshotItem.albumTitle?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setAlbumTitle(it) } - MediaItemBuilder.externalControllerArtworkUri(this, snapshotItem.artworkUri) - ?.let { metadataBuilder.setArtworkUri(it) } - - val extras = Bundle().apply { - putBoolean( - MediaItemBuilder.EXTERNAL_EXTRA_FLAG, - snapshotItem.mediaId.startsWith("external:") - ) - putString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI, snapshotItem.uri) - snapshotItem.albumTitle?.takeIf { it.isNotBlank() }?.let { - putString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM, it) - } - snapshotItem.artworkUri?.takeIf { it.isNotBlank() }?.let { - putString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART, it) - } - snapshotItem.durationMs?.takeIf { it > 0L }?.let { - putLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION, it) - } - } - metadataBuilder.setExtras(extras) - - return MediaItem.Builder() - .setMediaId(snapshotItem.mediaId) - .setUri(MediaItemBuilder.playbackUri(snapshotItem.uri)) - .setMediaMetadata(metadataBuilder.build()) - .build() + snapshotManager.schedulePersist(immediate = true) } private fun getOpenAppPendingIntent(): PendingIntent { @@ -1962,7 +1633,7 @@ class MusicService : MediaLibraryService() { var title = currentItem?.mediaMetadata?.title?.toString().orEmpty() var artist = currentItem?.mediaMetadata?.artist?.toString().orEmpty() var mediaId = currentItem?.mediaId - var artworkUri = resolveWidgetArtworkUriCandidates(currentItem?.mediaMetadata).firstOrNull() + var artworkUri = widgetArtworkResolver.resolveWidgetArtworkUriCandidates(currentItem?.mediaMetadata).firstOrNull() var artworkData = currentItem?.mediaMetadata?.artworkData castSyncCoordinator.resolveRemoteSnapshot()?.let { remote -> @@ -1987,11 +1658,11 @@ class MusicService : MediaLibraryService() { shuffleEnabled = remote.isShuffleEnabled } - val artworkCandidates = resolveWidgetArtworkUriCandidates( + val artworkCandidates = widgetArtworkResolver.resolveWidgetArtworkUriCandidates( metadata = currentItem?.mediaMetadata, preferredArtworkUri = artworkUri, ) - val (artBytes, artUriString) = getAlbumArtForWidget( + val (artBytes, artUriString) = widgetArtworkResolver.getAlbumArtForWidget( mediaId = mediaId, embeddedArt = artworkData, artUris = artworkCandidates, @@ -2087,13 +1758,13 @@ class MusicService : MediaLibraryService() { val mediaItem = window.mediaItem val songId = mediaItem.mediaId.toLongOrNull() if (songId != null) { - val initialQueueArtworkUri = resolveWidgetArtworkUriCandidates(mediaItem.mediaMetadata) + val initialQueueArtworkUri = widgetArtworkResolver.resolveWidgetArtworkUriCandidates(mediaItem.mediaMetadata) .firstOrNull() val queueArtworkUri = when { - initialQueueArtworkUri == null -> resolveRepositoryArtworkUri(mediaItem.mediaId) + initialQueueArtworkUri == null -> widgetArtworkResolver.resolveRepositoryArtworkUri(mediaItem.mediaId) initialQueueArtworkUri.scheme?.lowercase() == "content" && initialQueueArtworkUri.authority == "$packageName.provider" -> - resolveRepositoryArtworkUri(mediaItem.mediaId) ?: initialQueueArtworkUri + widgetArtworkResolver.resolveRepositoryArtworkUri(mediaItem.mediaId) ?: initialQueueArtworkUri else -> initialQueueArtworkUri } queueItems.add( @@ -2143,256 +1814,6 @@ class MusicService : MediaLibraryService() { private var cachedSchemePaletteStyle: AlbumArtPaletteStyle? = null private var cachedSchemeColorAccuracy: Int = AlbumArtColorAccuracy.DEFAULT private var cachedColorSchemePair: ColorSchemePair? = null - private var cachedWidgetArtSourceKey: String? = null - private var cachedWidgetArtResolvedUri: String? = null - private var cachedWidgetArtBytes: ByteArray? = null - private var cachedWidgetArtLoadFailureKey: String? = null - private var cachedWidgetArtLoadFailureAtMs: Long = 0L - - private fun invalidateCachedWidgetArtwork() { - cachedWidgetArtSourceKey = null - cachedWidgetArtResolvedUri = null - cachedWidgetArtBytes = null - cachedWidgetArtLoadFailureKey = null - cachedWidgetArtLoadFailureAtMs = 0L - } - - private fun isCurrentWidgetArtworkBackedByTelegram(): Boolean { - val currentItem = engine.masterPlayer.currentMediaItem ?: return false - val metadata = currentItem.mediaMetadata - val contentUriString = currentItem.localConfiguration?.uri?.toString() - ?: metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) - val artworkUriString = resolveStoredArtworkUriString(metadata) - return contentUriString?.startsWith("telegram://") == true || - artworkUriString?.startsWith("telegram_art://") == true - } - - private suspend fun getAlbumArtForWidget( - mediaId: String?, - embeddedArt: ByteArray?, - artUris: List, - ): Pair = withContext(Dispatchers.IO) { - // Try embedded art first — but fall through to URI loading if sanitization fails - val sanitizedFromEmbedded = embeddedArt?.takeIf { it.isNotEmpty() }?.let { bytes -> - runCatching { - ArtworkTransportSanitizer.sanitizeEncodedBytes( - data = bytes, - config = ArtworkTransportSanitizer.WIDGET_CONFIG, - ) - }.getOrNull() - } - val candidateUriStrings = LinkedHashSet().apply { - artUris.forEach { candidate -> - candidate.toString() - .takeIf { it.isNotBlank() } - ?.let(::add) - } - }.toList() - val preferredUriString = candidateUriStrings.firstOrNull() - val sourceKey = buildWidgetArtworkSourceKey( - mediaId = mediaId, - candidateUriStrings = candidateUriStrings, - ) - - if (sanitizedFromEmbedded != null) { - cachedWidgetArtSourceKey = sourceKey - cachedWidgetArtResolvedUri = preferredUriString - cachedWidgetArtBytes = sanitizedFromEmbedded - cachedWidgetArtLoadFailureKey = null - cachedWidgetArtLoadFailureAtMs = 0L - return@withContext sanitizedFromEmbedded to preferredUriString - } - - if (sourceKey != null && sourceKey == cachedWidgetArtSourceKey && cachedWidgetArtBytes != null) { - return@withContext cachedWidgetArtBytes to (cachedWidgetArtResolvedUri ?: preferredUriString) - } - if (sourceKey != null && sourceKey == cachedWidgetArtLoadFailureKey) { - val failureAgeMs = SystemClock.elapsedRealtime() - cachedWidgetArtLoadFailureAtMs - if (failureAgeMs < WIDGET_ART_FAILURE_RETRY_MS) { - return@withContext null to preferredUriString - } - } - - val repositoryArtUriString = if (mediaId.isNullOrBlank()) { - null - } else { - resolveRepositoryArtworkUri(mediaId)?.toString() - } - val resolvedUriStrings = LinkedHashSet().apply { - addAll(candidateUriStrings) - repositoryArtUriString - ?.takeIf { it.isNotBlank() } - ?.let(::add) - } - - for (candidateUriString in resolvedUriStrings) { - val candidateUri = parseArtworkUriString(candidateUriString) ?: continue - val loadedBytes = loadArtworkBytesForWidget(candidateUri) - if (loadedBytes != null) { - cachedWidgetArtSourceKey = sourceKey - cachedWidgetArtResolvedUri = candidateUriString - cachedWidgetArtBytes = loadedBytes - cachedWidgetArtLoadFailureKey = null - cachedWidgetArtLoadFailureAtMs = 0L - return@withContext loadedBytes to candidateUriString - } - } - - cachedWidgetArtLoadFailureKey = sourceKey - cachedWidgetArtLoadFailureAtMs = SystemClock.elapsedRealtime() - return@withContext null to (repositoryArtUriString ?: preferredUriString) - } - - private fun resolveStoredArtworkUriString(metadata: MediaMetadata?): String? { - metadata ?: return null - return metadata.extras - ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART) - ?.takeIf { it.isNotBlank() } - ?: metadata.artworkUri - ?.toString() - ?.takeIf { it.isNotBlank() } - } - - private fun resolveWidgetArtworkUriCandidates( - metadata: MediaMetadata?, - preferredArtworkUri: Uri? = null, - ): List { - val candidates = LinkedHashSet() - preferredArtworkUri - ?.toString() - ?.takeIf { it.isNotBlank() } - ?.let(candidates::add) - resolveStoredArtworkUriString(metadata)?.let(candidates::add) - metadata?.artworkUri - ?.toString() - ?.takeIf { it.isNotBlank() } - ?.let(candidates::add) - return candidates.mapNotNull(::parseArtworkUriString) - } - - private fun parseArtworkUriString(rawArtworkUri: String?): Uri? { - if (rawArtworkUri.isNullOrBlank()) { - return null - } - - return MediaItemBuilder.artworkUri(rawArtworkUri) - ?: if (rawArtworkUri.startsWith("/")) { - Uri.fromFile(java.io.File(rawArtworkUri)) - } else { - runCatching { rawArtworkUri.toUri() }.getOrNull() - } - } - - private fun buildWidgetArtworkSourceKey( - mediaId: String?, - candidateUriStrings: List, - ): String? { - val normalizedMediaId = mediaId?.takeIf { it.isNotBlank() } - if (normalizedMediaId == null && candidateUriStrings.isEmpty()) { - return null - } - return buildString { - normalizedMediaId?.let { - append("mediaId=") - append(it) - } - if (candidateUriStrings.isNotEmpty()) { - if (isNotEmpty()) append('|') - append(candidateUriStrings.joinToString(separator = ",")) - } - } - } - - private fun resolveArtworkUri(metadata: MediaMetadata?): Uri? { - metadata ?: return null - metadata.artworkUri?.let { return it } - val extrasUri = metadata.extras - ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART) - ?.takeIf { it.isNotBlank() } - ?: return null - return parseArtworkUriString(extrasUri) - } - - private suspend fun resolveRepositoryArtworkUri(mediaId: String?): Uri? { - val songId = mediaId?.takeIf { it.isNotBlank() } ?: return null - val song = withContext(Dispatchers.IO) { - musicRepository.getSong(songId).first() - } ?: return null - - return MediaItemBuilder.artworkUri(song.albumArtUriString) - ?: song.albumArtUriString - ?.takeIf { it.isNotBlank() } - ?.let { raw -> - if (raw.startsWith("/")) Uri.fromFile(java.io.File(raw)) - else runCatching { Uri.parse(raw) }.getOrNull() - } - } - - public suspend fun loadArtworkBytesForWidget(uri: Uri): ByteArray? { - val uriString = uri.toString() - val scheme = uri.scheme?.lowercase() - val isLocalArtworkUri = com.theveloper.pixelplay.utils.LocalArtworkUri.isLocalArtworkUri(uriString) - return when { - isLocalArtworkUri || scheme == "content" || scheme == "file" || scheme == "android.resource" -> { - runCatching { - AlbumArtUtils.openArtworkInputStream(applicationContext, uri)?.use { input -> - readBytesCapped(input, ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit) - ?.let { bytes -> - ArtworkTransportSanitizer.sanitizeEncodedBytes( - data = bytes, - config = ArtworkTransportSanitizer.WIDGET_CONFIG, - ) - } - } - }.getOrElse { error -> - Timber.tag(TAG).w(error, "Widget artwork read failed for local uri=%s", uri) - null - } - } - scheme == "http" || scheme == "https" -> { - var connection: HttpURLConnection? = null - try { - connection = (URL(uriString).openConnection() as? HttpURLConnection) - ?: return null - connection.connectTimeout = 4_000 - connection.readTimeout = 6_000 - connection.instanceFollowRedirects = true - connection.doInput = true - connection.inputStream.use { input -> - readBytesCapped(input, ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit) - ?.let { bytes -> - ArtworkTransportSanitizer.sanitizeEncodedBytes( - data = bytes, - config = ArtworkTransportSanitizer.WIDGET_CONFIG, - ) - } - } - } catch (error: Exception) { - Timber.tag(TAG).w(error, "Widget artwork read failed for remote uri=%s", uri) - null - } finally { - connection?.disconnect() - } - } - else -> loadArtworkBytesViaCoil(applicationContext, uri) - } - } - - private fun readBytesCapped(input: java.io.InputStream, maxBytes: Int): ByteArray? { - // Pre-size to 4× the read-buffer to reduce reallocation churn on typical album art - // (50–300 KB). Still far below the maxBytes cap enforced in the loop below. - val output = ByteArrayOutputStream(DEFAULT_STREAM_BUFFER_SIZE * 4) - val buffer = ByteArray(DEFAULT_STREAM_BUFFER_SIZE) - var totalRead = 0 - while (true) { - val read = input.read(buffer) - if (read <= 0) break - totalRead += read - if (totalRead > maxBytes) return null - output.write(buffer, 0, read) - } - return output.toByteArray().takeIf { it.isNotEmpty() } - } fun isSongFavorite(songId: String?): Boolean { return songId != null && favoriteSongIds.contains(songId) @@ -2497,23 +1918,22 @@ class MusicService : MediaLibraryService() { "Stopping playback and unloading service. reason=%s", reason ) - isPlaybackUnloadInProgress = true + snapshotManager.isUnloadInProgress = true followUpMediaSessionUiRefreshJob?.cancel() mediaSessionButtonRefreshJob?.cancel() widgetUpdateManager.cancel() - playbackSnapshotPersistJob?.cancel() val sessionToRelease = mediaSession val player = sessionToRelease?.player ?: engine.masterPlayer clearHeadsetReconnectResume() cancelDurationSleepTimerInternal() - endOfTrackTimerSongId = null + cancelSleepTimers() if (preservePlaybackSnapshot) { - persistPlaybackSnapshotOnUnload() + snapshotManager.persistOnUnload() } else { - clearPlaybackSnapshotOnUnload() + snapshotManager.clearOnUnload() } listeningStatsTracker.finalizeCurrentSession(forceSynchronousPersistence = true) @@ -2528,26 +1948,6 @@ class MusicService : MediaLibraryService() { stopSelf() } - private fun persistPlaybackSnapshotOnUnload() { - val snapshot = capturePlaybackSnapshotFromPlayer(playWhenReadyOverride = false) - writePlaybackSnapshotOnUnload(snapshot) - } - - private fun clearPlaybackSnapshotOnUnload() { - writePlaybackSnapshotOnUnload(null) - } - - private fun writePlaybackSnapshotOnUnload(snapshot: PlaybackQueueSnapshot?) { - playbackSnapshotUnloadWriteJob?.cancel() - playbackSnapshotUnloadWriteJob = appScope.launch { - runCatching { - userPreferencesRepository.setPlaybackQueueSnapshot(snapshot) - }.onFailure { e -> - Timber.tag(TAG).w(e, "Failed to persist playback snapshot during unload") - } - } - } - private fun refreshMediaSessionUiWithFollowUp( session: MediaSession, delayMs: Long = 250L @@ -2716,7 +2116,7 @@ class MusicService : MediaLibraryService() { val providerAuthority = "$packageName.provider" val artworkAuthority = "$packageName.artwork" mediaItems.forEach { mediaItem -> - val artworkUri = resolveArtworkUri(mediaItem.mediaMetadata) ?: return@forEach + val artworkUri = widgetArtworkResolver.resolveArtworkUri(mediaItem.mediaMetadata) ?: return@forEach val authority = artworkUri.authority if (artworkUri.scheme?.lowercase() != "content" || (authority != providerAuthority && authority != artworkAuthority) @@ -2824,80 +2224,9 @@ class MusicService : MediaLibraryService() { // ------------------------ // Counted Play Controls // ------------------------ - fun startCountedPlay(count: Int) { - val player = engine.masterPlayer - val currentItem = player.currentMediaItem ?: return - - stopCountedPlay() // reset previous - - countedPlayTarget = count - countedPlayCount = 1 - countedOriginalId = currentItem.mediaId - countedPlayActive = true - - // Force repeat-one - player.repeatMode = Player.REPEAT_MODE_ONE - - val listener = object : Player.Listener { - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - if (!countedPlayActive) return - - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - countedPlayCount++ - - if (countedPlayCount > countedPlayTarget) { - player.pause() - stopCountedPlay() - return - } - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (!countedPlayActive) return + fun startCountedPlay(count: Int) = countedPlayController.start(count) - // If user manually changes the song -> cancel - if (mediaItem?.mediaId != countedOriginalId) { - stopCountedPlay() - } - } - - override fun onRepeatModeChanged(repeatMode: Int) { - // User explicitly changed repeat mode while counted play is active: - // cancel counted play and accept the new mode instead of fighting back. - if (countedPlayActive && repeatMode != Player.REPEAT_MODE_ONE) { - stopCountedPlay(restoreRepeatMode = false) - } - } - } - - countedPlayListener = listener - player.addListener(listener) - } - - fun stopCountedPlay(restoreRepeatMode: Boolean = true) { - if (!countedPlayActive) return - - countedPlayActive = false - countedPlayTarget = 0 - countedPlayCount = 0 - countedOriginalId = null - - countedPlayListener?.let { - engine.masterPlayer.removeListener(it) - } - countedPlayListener = null - - // Restore normal repeat mode (OFF) only when not triggered by a user repeat-mode change - if (restoreRepeatMode) { - engine.masterPlayer.repeatMode = Player.REPEAT_MODE_OFF - } - } + fun stopCountedPlay(restoreRepeatMode: Boolean = true) = countedPlayController.stop(restoreRepeatMode) /** * Bridges a suspend block into a [ListenableFuture] for Media3 callback methods. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/playback/CountedPlayController.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/CountedPlayController.kt new file mode 100644 index 0000000000..8083850f9d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/CountedPlayController.kt @@ -0,0 +1,86 @@ +package com.theveloper.pixelplay.data.service.playback + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.theveloper.pixelplay.data.service.player.DualPlayerEngine + +/** + * Manages "counted play" — play a track N times then pause. + * Used by the notification and Wear for "play 1/3/5 times" features. + * + * Extracted from [MusicService] to isolate counted-play state tracking + * from the service's media-session lifecycle management. + */ +class CountedPlayController( + private val engine: DualPlayerEngine, +) { + + private var active = false + private var target = 0 + private var count = 0 + private var originalId: String? = null + private var listener: Player.Listener? = null + + fun start(count: Int) { + val player = engine.masterPlayer + val currentItem = player.currentMediaItem ?: return + + stop() + + target = count + this.count = 1 + originalId = currentItem.mediaId + active = true + + player.repeatMode = Player.REPEAT_MODE_ONE + + val l = object : Player.Listener { + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (!active) return + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { + this@CountedPlayController.count++ + if (this@CountedPlayController.count > target) { + player.pause() + stop() + } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (!active) return + if (mediaItem?.mediaId != originalId) { + stop() + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + if (active && repeatMode != Player.REPEAT_MODE_ONE) { + stop(restoreRepeatMode = false) + } + } + } + + listener = l + player.addListener(l) + } + + fun stop(restoreRepeatMode: Boolean = true) { + if (!active) return + + active = false + target = 0 + count = 0 + originalId = null + + listener?.let { engine.masterPlayer.removeListener(it) } + listener = null + + if (restoreRepeatMode) { + engine.masterPlayer.repeatMode = Player.REPEAT_MODE_OFF + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/playback/HeadsetReconnectMonitor.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/HeadsetReconnectMonitor.kt new file mode 100644 index 0000000000..75a7d7df5e --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/HeadsetReconnectMonitor.kt @@ -0,0 +1,107 @@ +package com.theveloper.pixelplay.data.service.playback + +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.SystemClock +import androidx.media3.common.Player +import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import timber.log.Timber + +/** + * Monitors audio device changes and resumes playback after headset reconnection. + * + * Extracted from [MusicService] to isolate headset monitoring from the + * service's media-session lifecycle management. + */ +class HeadsetReconnectMonitor( + private val audioManager: AudioManager, + private val engine: DualPlayerEngine, + private val isResumeEnabled: () -> Boolean, + private val currentPlayer: () -> Player?, +) { + + private var callback: AudioDeviceCallback? = null + private var shouldResume = false + private var lastNoisyPauseRealtimeMs = 0L + + fun register() { + val deviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + if (!addedDevices.any(::isReconnectableHeadsetOutput)) return + maybeResume() + } + } + audioManager.registerAudioDeviceCallback(deviceCallback, null) + callback = deviceCallback + } + + fun unregister() { + callback?.let { c -> + runCatching { audioManager.unregisterAudioDeviceCallback(c) } + } + callback = null + clearResume() + } + + fun markNoisyPause() { + shouldResume = true + lastNoisyPauseRealtimeMs = SystemClock.elapsedRealtime() + Timber.tag(TAG).d("Marked playback for headset reconnect resume") + } + + fun clearResume() { + shouldResume = false + lastNoisyPauseRealtimeMs = 0L + } + + private fun maybeResume() { + if (!isResumeEnabled() || !shouldResume) return + + val elapsedSinceNoisyPause = SystemClock.elapsedRealtime() - lastNoisyPauseRealtimeMs + if (elapsedSinceNoisyPause > RESUME_WINDOW_MS) { + clearResume() + return + } + + if (!hasReconnectableHeadsetOutput()) return + + val player = currentPlayer() ?: return + if ( + player.currentMediaItem == null || + player.playWhenReady || + player.playbackState == Player.STATE_IDLE || + player.playbackState == Player.STATE_ENDED + ) { + clearResume() + return + } + + Timber.tag(TAG).d("Resuming playback after headset reconnect") + clearResume() + player.play() + } + + private fun hasReconnectableHeadsetOutput(): Boolean { + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .any(::isReconnectableHeadsetOutput) + } + + private fun isReconnectableHeadsetOutput(device: AudioDeviceInfo): Boolean { + return when (device.type) { + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER -> true + else -> false + } + } + + companion object { + private const val TAG = "HeadsetReconnectMonitor" + private const val RESUME_WINDOW_MS = 15_000L + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/playback/NavidromePlaybackReporter.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/NavidromePlaybackReporter.kt new file mode 100644 index 0000000000..5e11cb899a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/NavidromePlaybackReporter.kt @@ -0,0 +1,86 @@ +package com.theveloper.pixelplay.data.service.playback + +import androidx.media3.common.MediaItem +import com.theveloper.pixelplay.data.navidrome.NavidromeRepository +import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import com.theveloper.pixelplay.utils.MediaItemBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Handles Navidrome scrobble/playback reporting: periodic "playing" reports + * and "stopped"/"paused" reports on state changes. + * + * Extracted from [MusicService] to isolate Navidrome-specific reporting + * from the service's media-session lifecycle management. + */ +class NavidromePlaybackReporter( + private val navidromeRepository: NavidromeRepository, + private val engine: DualPlayerEngine, + private val serviceScope: CoroutineScope, + private val appScope: CoroutineScope, +) { + + private var reportJob: Job? = null + + fun getNavidromeId(mediaItem: MediaItem?): String? { + mediaItem ?: return null + return mediaItem.mediaMetadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_SONG_ID) + ?: mediaItem.mediaId.let { if (it.startsWith("navidrome_")) it.substringAfter("navidrome_") else null } + ?: mediaItem.mediaMetadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI)?.let { + if (it.startsWith("navidrome://")) it.substringAfter("navidrome://") else null + } + } + + fun isNavidromeMediaItem(mediaItem: MediaItem?): Boolean { + return getNavidromeId(mediaItem) != null + } + + fun reportPlayback(state: String, mediaItem: MediaItem? = engine.masterPlayer.currentMediaItem) { + val player = engine.masterPlayer + val targetItem = mediaItem ?: return + val navidromeId = getNavidromeId(targetItem) ?: return + + val positionMs = if (targetItem === player.currentMediaItem) { + player.currentPosition + } else { + targetItem.mediaMetadata.extras?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) ?: 0L + } + val playbackRate = player.playbackParameters.speed + + appScope.launch(Dispatchers.IO) { + navidromeRepository.reportPlayback( + navidromeId = navidromeId, + positionMs = positionMs, + state = state, + playbackRate = playbackRate + ) + } + } + + fun startReporting() { + reportJob?.cancel() + reportJob = serviceScope.launch { + while (true) { + delay(30_000) + val player = engine.masterPlayer + if (player.isPlaying && isNavidromeMediaItem(player.currentMediaItem)) { + reportPlayback("playing") + } + } + } + } + + fun stopReporting() { + reportJob?.cancel() + reportJob = null + } + + companion object { + private const val TAG = "NavidromePlaybackReporter" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/playback/PlaybackSnapshotManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/PlaybackSnapshotManager.kt new file mode 100644 index 0000000000..11cbc5a875 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/PlaybackSnapshotManager.kt @@ -0,0 +1,234 @@ +package com.theveloper.pixelplay.data.service.playback + +import android.content.Context +import android.os.Bundle +import androidx.media3.common.Player +import com.theveloper.pixelplay.data.model.PlaybackQueueItemSnapshot +import com.theveloper.pixelplay.data.model.PlaybackQueueSnapshot +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import com.theveloper.pixelplay.utils.MediaItemBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Manages capture, persistence, and debounced scheduling of the playback + * queue snapshot used to restore playback state across process restarts. + * + * Extracted from [MusicService] to isolate snapshot I/O from the service's + * media-session lifecycle management. + */ +class PlaybackSnapshotManager( + private val context: Context, + private val engine: DualPlayerEngine, + private val userPreferencesRepository: UserPreferencesRepository, + private val scope: CoroutineScope, +) { + + private var persistJob: Job? = null + private var unloadWriteJob: Job? = null + var isRestoring: Boolean = false + private set + var isUnloadInProgress: Boolean = false + private set + + fun schedulePersist(immediate: Boolean = false) { + if (isUnloadInProgress) return + persistJob?.cancel() + persistJob = scope.launch { + if (!immediate) { + delay(SNAPSHOT_DEBOUNCE_MS) + } + persist() + } + } + + suspend fun persist(playWhenReadyOverride: Boolean? = null) { + if (isRestoring) return + val snapshot = capture(playWhenReadyOverride) + runCatching { + userPreferencesRepository.setPlaybackQueueSnapshot(snapshot) + }.onFailure { e -> + Timber.tag(TAG).w(e, "Failed to persist playback snapshot") + } + } + + suspend fun capture(playWhenReadyOverride: Boolean? = null): PlaybackQueueSnapshot? = + withContext(Dispatchers.Main.immediate) { + captureFromPlayer(playWhenReadyOverride) + } + + fun captureFromPlayer( + playWhenReadyOverride: Boolean? = null + ): PlaybackQueueSnapshot? { + val player = engine.masterPlayer + val mediaItemCount = player.mediaItemCount + if (mediaItemCount <= 0) { + return null + } + + val snapshotItems = ArrayList(mediaItemCount) + for (index in 0 until mediaItemCount) { + val mediaItem = player.getMediaItemAt(index) + val metadata = mediaItem.mediaMetadata + val uri = mediaItem.localConfiguration?.uri?.toString() + ?: metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) + + if (mediaItem.mediaId.isBlank() || uri.isNullOrBlank()) { + continue + } + + val durationMs = metadata.extras + ?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) + ?.takeIf { it > 0L } + + snapshotItems.add( + PlaybackQueueItemSnapshot( + mediaId = mediaItem.mediaId, + uri = uri, + title = metadata.title?.toString(), + artist = metadata.artist?.toString(), + albumTitle = metadata.albumTitle?.toString(), + artworkUri = resolveStoredArtworkUri(metadata), + durationMs = durationMs, + ) + ) + } + + if (snapshotItems.isEmpty()) { + return null + } + + val currentMediaId = player.currentMediaItem?.mediaId + val indexFromMediaId = currentMediaId + ?.let { id -> snapshotItems.indexOfFirst { it.mediaId == id } } + ?.takeIf { it >= 0 } + + val safeCurrentIndex = when { + indexFromMediaId != null -> indexFromMediaId + player.currentMediaItemIndex in snapshotItems.indices -> player.currentMediaItemIndex + else -> 0 + } + + val safeRepeatMode = when (player.repeatMode) { + Player.REPEAT_MODE_OFF, + Player.REPEAT_MODE_ONE, + Player.REPEAT_MODE_ALL -> player.repeatMode + else -> Player.REPEAT_MODE_OFF + } + + return PlaybackQueueSnapshot( + items = snapshotItems, + currentMediaId = currentMediaId, + currentIndex = safeCurrentIndex, + currentPositionMs = player.currentPosition.coerceAtLeast(0L), + playWhenReady = playWhenReadyOverride ?: player.playWhenReady, + repeatMode = safeRepeatMode, + shuffleEnabled = isShuffleEnabled(), + ) + } + + fun persistOnUnload() { + val snapshot = captureFromPlayer(playWhenReadyOverride = false) + writeOnUnload(snapshot) + } + + fun clearOnUnload() { + writeOnUnload(null) + } + + private fun writeOnUnload(snapshot: PlaybackQueueSnapshot?) { + unloadWriteJob?.cancel() + unloadWriteJob = scope.launch { + runCatching { + userPreferencesRepository.setPlaybackQueueSnapshot(snapshot) + }.onFailure { e -> + Timber.tag(TAG).w(e, "Failed to persist playback snapshot during unload") + } + } + } + + fun beginRestore() { + isRestoring = true + } + + fun endRestore() { + isRestoring = false + } + + /** + * Build a [MediaItem] from a snapshot item for queue restoration. + * Kept here because it depends on [MediaItemBuilder] which is a utility. + */ + fun buildMediaItemFromSnapshot(snapshotItem: PlaybackQueueItemSnapshot): android.media3.common.MediaItem? { + if (snapshotItem.mediaId.isBlank() || snapshotItem.uri.isBlank()) { + return null + } + + val metadataBuilder = androidx.media3.common.MediaMetadata.Builder() + snapshotItem.title?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setTitle(it) } + snapshotItem.artist?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setArtist(it) } + snapshotItem.albumTitle?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setAlbumTitle(it) } + MediaItemBuilder.externalControllerArtworkUri(context, snapshotItem.artworkUri) + ?.let { metadataBuilder.setArtworkUri(it) } + + val extras = Bundle().apply { + putBoolean( + MediaItemBuilder.EXTERNAL_EXTRA_FLAG, + snapshotItem.mediaId.startsWith("external:") + ) + putString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI, snapshotItem.uri) + snapshotItem.albumTitle?.takeIf { it.isNotBlank() }?.let { + putString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM, it) + } + snapshotItem.artworkUri?.takeIf { it.isNotBlank() }?.let { + putString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART, it) + } + snapshotItem.durationMs?.takeIf { it > 0L }?.let { + putLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION, it) + } + } + metadataBuilder.setExtras(extras) + + return android.media3.common.MediaItem.Builder() + .setMediaId(snapshotItem.mediaId) + .setUri(MediaItemBuilder.playbackUri(snapshotItem.uri)) + .setMediaMetadata(metadataBuilder.build()) + .build() + } + + /** + * Placeholder for shuffle state — overridden by MusicService lambda. + * Set by MusicService via [setShuffleStateProvider]. + */ + private var shuffleStateProvider: () -> Boolean = { false } + + fun setShuffleStateProvider(provider: () -> Boolean) { + shuffleStateProvider = provider + } + + private fun isShuffleEnabled(): Boolean = shuffleStateProvider() + + /** + * Reads the stored artwork URI from media item metadata. + */ + private fun resolveStoredArtworkUri(metadata: androidx.media3.common.MediaMetadata?): String? { + metadata ?: return null + return metadata.extras + ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART) + ?.takeIf { it.isNotBlank() } + ?: metadata.artworkUri + ?.toString() + ?.takeIf { it.isNotBlank() } + } + + companion object { + private const val TAG = "PlaybackSnapshotManager" + private const val SNAPSHOT_DEBOUNCE_MS = 1500L + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/playback/SleepTimerController.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/SleepTimerController.kt new file mode 100644 index 0000000000..126b1579ba --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/playback/SleepTimerController.kt @@ -0,0 +1,126 @@ +package com.theveloper.pixelplay.data.service.playback + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.media3.common.Player +import com.theveloper.pixelplay.data.service.SleepTimerReceiver +import timber.log.Timber + +/** + * Manages duration-based and end-of-track sleep timers. + * + * Extracted from [MusicService] to isolate timer scheduling and + * cancellation logic from the service's media-session lifecycle. + */ +class SleepTimerController( + private val context: Context, + private val alarmManager: AlarmManager, + private val actionSleepTimerExpired: String, + private val currentPlayer: () -> Player?, + private val currentMediaId: () -> String?, +) { + + var endOfTrackTimerSongId: String? = null + private set + + fun createSleepTimerPendingIntent(): PendingIntent { + val intent = Intent(context, SleepTimerReceiver::class.java).apply { + action = actionSleepTimerExpired + setPackage(context.packageName) + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + fun cancelDurationSleepTimerInternal() { + alarmManager.cancel(createSleepTimerPendingIntent()) + } + + fun setDurationSleepTimer(minutes: Int) { + if (minutes <= 0) { + cancelSleepTimers() + return + } + endOfTrackTimerSongId = null + val triggerAtMillis = System.currentTimeMillis() + (minutes * 60_000L) + val pendingIntent = createSleepTimerPendingIntent() + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent, + ) + } else { + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent, + ) + } + } else + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtMillis, + pendingIntent, + ) + Timber.tag(TAG).d("Sleep timer set for %d minutes", minutes) + } catch (e: SecurityException) { + Timber.tag(TAG).w(e, "Exact alarm denied; using inexact sleep timer") + alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) + } + } + + fun setEndOfTrackSleepTimer(enabled: Boolean) { + if (!enabled) { + endOfTrackTimerSongId = null + Timber.tag(TAG).d("End-of-track timer disabled") + return + } + cancelDurationSleepTimerInternal() + val songId = currentMediaId() + if (songId.isNullOrBlank()) { + endOfTrackTimerSongId = null + Timber.tag(TAG).d("End-of-track timer ignored: no active song") + return + } + endOfTrackTimerSongId = songId + Timber.tag(TAG).d("End-of-track timer set for mediaId=%s", songId) + } + + fun cancelSleepTimers() { + cancelDurationSleepTimerInternal() + endOfTrackTimerSongId = null + Timber.tag(TAG).d("Sleep timers cancelled") + } + + /** + * Checks if the current track matches the end-of-track timer and pauses if so. + * Returns true if the timer fired, false otherwise. + */ + fun checkEndOfTrackTimer(): Boolean { + val timerSongId = endOfTrackTimerSongId ?: return false + val player = currentPlayer() ?: return false + val currentId = player.currentMediaItem?.mediaId + if (currentId == timerSongId && player.isPlaying) { + player.pause() + endOfTrackTimerSongId = null + Timber.tag(TAG).d("End-of-track timer fired: paused playback") + return true + } + return false + } + + companion object { + private const val TAG = "SleepTimerController" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/widget/WidgetArtworkResolver.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/widget/WidgetArtworkResolver.kt new file mode 100644 index 0000000000..69df8fa7e8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/widget/WidgetArtworkResolver.kt @@ -0,0 +1,332 @@ +package com.theveloper.pixelplay.data.service.widget + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.SystemClock +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import coil.imageLoader +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.size.Precision +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.utils.ArtworkTransportSanitizer +import com.theveloper.pixelplay.utils.LocalArtworkUri +import com.theveloper.pixelplay.utils.MediaItemBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * Resolves, caches, and loads artwork bytes for Glance home-screen widgets. + * + * Extracted from [MusicService] to isolate widget artwork I/O concerns + * (URI resolution, byte loading, failure caching) from the service's + * media-session lifecycle management. + */ +@UnstableApi +class WidgetArtworkResolver( + private val context: Context, + private val engine: DualPlayerEngine, + private val musicRepository: MusicRepository, +) { + + private var cachedWidgetArtSourceKey: String? = null + private var cachedWidgetArtResolvedUri: String? = null + private var cachedWidgetArtBytes: ByteArray? = null + private var cachedWidgetArtLoadFailureKey: String? = null + private var cachedWidgetArtLoadFailureAtMs: Long = 0L + + fun invalidateCachedWidgetArtwork() { + cachedWidgetArtSourceKey = null + cachedWidgetArtResolvedUri = null + cachedWidgetArtBytes = null + cachedWidgetArtLoadFailureKey = null + cachedWidgetArtLoadFailureAtMs = 0L + } + + fun isCurrentWidgetArtworkBackedByTelegram(): Boolean { + val currentItem = engine.masterPlayer.currentMediaItem ?: return false + val metadata = currentItem.mediaMetadata + val contentUriString = currentItem.localConfiguration?.uri?.toString() + ?: metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) + val artworkUriString = resolveStoredArtworkUriString(metadata) + return contentUriString?.startsWith("telegram://") == true || + artworkUriString?.startsWith("telegram_art://") == true + } + + suspend fun getAlbumArtForWidget( + mediaId: String?, + embeddedArt: ByteArray?, + artUris: List, + ): Pair = withContext(Dispatchers.IO) { + val sanitizedFromEmbedded = embeddedArt?.takeIf { it.isNotEmpty() }?.let { bytes -> + runCatching { + ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = bytes, + config = ArtworkTransportSanitizer.WIDGET_CONFIG, + ) + }.getOrNull() + } + val candidateUriStrings = LinkedHashSet().apply { + artUris.forEach { candidate -> + candidate.toString() + .takeIf { it.isNotBlank() } + ?.let(::add) + } + }.toList() + val preferredUriString = candidateUriStrings.firstOrNull() + val sourceKey = buildWidgetArtworkSourceKey( + mediaId = mediaId, + candidateUriStrings = candidateUriStrings, + ) + + if (sanitizedFromEmbedded != null) { + cachedWidgetArtSourceKey = sourceKey + cachedWidgetArtResolvedUri = preferredUriString + cachedWidgetArtBytes = sanitizedFromEmbedded + cachedWidgetArtLoadFailureKey = null + cachedWidgetArtLoadFailureAtMs = 0L + return@withContext sanitizedFromEmbedded to preferredUriString + } + + if (sourceKey != null && sourceKey == cachedWidgetArtSourceKey && cachedWidgetArtBytes != null) { + return@withContext cachedWidgetArtBytes to (cachedWidgetArtResolvedUri ?: preferredUriString) + } + if (sourceKey != null && sourceKey == cachedWidgetArtLoadFailureKey) { + val failureAgeMs = SystemClock.elapsedRealtime() - cachedWidgetArtLoadFailureAtMs + if (failureAgeMs < WIDGET_ART_FAILURE_RETRY_MS) { + return@withContext null to preferredUriString + } + } + + val repositoryArtUriString = if (mediaId.isNullOrBlank()) { + null + } else { + resolveRepositoryArtworkUri(mediaId)?.toString() + } + val resolvedUriStrings = LinkedHashSet().apply { + addAll(candidateUriStrings) + repositoryArtUriString + ?.takeIf { it.isNotBlank() } + ?.let(::add) + } + + for (candidateUriString in resolvedUriStrings) { + val candidateUri = parseArtworkUriString(candidateUriString) ?: continue + val loadedBytes = loadArtworkBytes(candidateUri) + if (loadedBytes != null) { + cachedWidgetArtSourceKey = sourceKey + cachedWidgetArtResolvedUri = candidateUriString + cachedWidgetArtBytes = loadedBytes + cachedWidgetArtLoadFailureKey = null + cachedWidgetArtLoadFailureAtMs = 0L + return@withContext loadedBytes to candidateUriString + } + } + + cachedWidgetArtLoadFailureKey = sourceKey + cachedWidgetArtLoadFailureAtMs = SystemClock.elapsedRealtime() + return@withContext null to (repositoryArtUriString ?: preferredUriString) + } + + fun resolveStoredArtworkUriString(metadata: MediaMetadata?): String? { + metadata ?: return null + return metadata.extras + ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART) + ?.takeIf { it.isNotBlank() } + ?: metadata.artworkUri + ?.toString() + ?.takeIf { it.isNotBlank() } + } + + fun resolveWidgetArtworkUriCandidates( + metadata: MediaMetadata?, + preferredArtworkUri: Uri? = null, + ): List { + val candidates = LinkedHashSet() + preferredArtworkUri + ?.toString() + ?.takeIf { it.isNotBlank() } + ?.let(candidates::add) + resolveStoredArtworkUriString(metadata)?.let(candidates::add) + metadata?.artworkUri + ?.toString() + ?.takeIf { it.isNotBlank() } + ?.let(candidates::add) + return candidates.mapNotNull(::parseArtworkUriString) + } + + fun resolveArtworkUri(metadata: MediaMetadata?): Uri? { + metadata ?: return null + metadata.artworkUri?.let { return it } + val extrasUri = metadata.extras + ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART) + ?.takeIf { it.isNotBlank() } + ?: return null + return parseArtworkUriString(extrasUri) + } + + suspend fun loadArtworkBytes(uri: Uri): ByteArray? { + val uriString = uri.toString() + val scheme = uri.scheme?.lowercase() + val isLocalArtworkUri = LocalArtworkUri.isLocalArtworkUri(uriString) + return when { + isLocalArtworkUri || scheme == "content" || scheme == "file" || scheme == "android.resource" -> { + runCatching { + AlbumArtUtils.openArtworkInputStream(context, uri)?.use { input -> + readBytesCapped(input, ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit) + ?.let { bytes -> + ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = bytes, + config = ArtworkTransportSanitizer.WIDGET_CONFIG, + ) + } + } + }.getOrElse { error -> + Timber.tag(TAG).w(error, "Widget artwork read failed for local uri=%s", uri) + null + } + } + scheme == "http" || scheme == "https" -> { + var connection: HttpURLConnection? = null + try { + connection = (URL(uriString).openConnection() as? HttpURLConnection) + ?: return null + connection.connectTimeout = 4_000 + connection.readTimeout = 6_000 + connection.instanceFollowRedirects = true + connection.doInput = true + connection.inputStream.use { input -> + readBytesCapped(input, ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit) + ?.let { bytes -> + ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = bytes, + config = ArtworkTransportSanitizer.WIDGET_CONFIG, + ) + } + } + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Widget artwork read failed for remote uri=%s", uri) + null + } finally { + connection?.disconnect() + } + } + else -> loadArtworkBytesViaCoil(context, uri) + } + } + + private fun parseArtworkUriString(rawArtworkUri: String?): Uri? { + if (rawArtworkUri.isNullOrBlank()) { + return null + } + + return MediaItemBuilder.artworkUri(rawArtworkUri) + ?: if (rawArtworkUri.startsWith("/")) { + Uri.fromFile(java.io.File(rawArtworkUri)) + } else { + runCatching { Uri.parse(rawArtworkUri) }.getOrNull() + } + } + + private fun buildWidgetArtworkSourceKey( + mediaId: String?, + candidateUriStrings: List, + ): String? { + val normalizedMediaId = mediaId?.takeIf { it.isNotBlank() } + if (normalizedMediaId == null && candidateUriStrings.isEmpty()) { + return null + } + return buildString { + normalizedMediaId?.let { + append("mediaId=") + append(it) + } + if (candidateUriStrings.isNotEmpty()) { + if (isNotEmpty()) append('|') + append(candidateUriStrings.joinToString(separator = ",")) + } + } + } + + suspend fun resolveRepositoryArtworkUri(mediaId: String?): Uri? { + val songId = mediaId?.takeIf { it.isNotBlank() } ?: return null + val song = withContext(Dispatchers.IO) { + musicRepository.getSong(songId).first() + } ?: return null + + return MediaItemBuilder.artworkUri(song.albumArtUriString) + ?: song.albumArtUriString + ?.takeIf { it.isNotBlank() } + ?.let { raw -> + if (raw.startsWith("/")) Uri.fromFile(java.io.File(raw)) + else runCatching { Uri.parse(raw) }.getOrNull() + } + } + + private fun readBytesCapped(input: InputStream, maxBytes: Int): ByteArray? { + val output = ByteArrayOutputStream(DEFAULT_STREAM_BUFFER_SIZE * 4) + val buffer = ByteArray(DEFAULT_STREAM_BUFFER_SIZE) + var totalRead = 0 + while (true) { + val read = input.read(buffer) + if (read <= 0) break + totalRead += read + if (totalRead > maxBytes) return null + output.write(buffer, 0, read) + } + return output.toByteArray().takeIf { it.isNotEmpty() } + } + + companion object { + private const val TAG = "WidgetArtworkResolver" + private const val DEFAULT_STREAM_BUFFER_SIZE = 8 * 1024 + private const val WIDGET_ART_FAILURE_RETRY_MS = 30_000L + } +} + +suspend fun loadArtworkBytesViaCoil(context: Context, uri: Uri): ByteArray? { + val appContext = context.applicationContext + val request = ImageRequest.Builder(appContext) + .data(uri) + .size( + ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx, + ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx, + ) + .precision(Precision.INEXACT) + .allowHardware(false) + .memoryCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .build() + + return runCatching { + val drawable = appContext.imageLoader.execute(request).drawable ?: return@runCatching null + val fallbackSizePx = ArtworkTransportSanitizer.WIDGET_CONFIG.maxDimensionPx + val bitmap = drawable.toBitmap( + width = drawable.intrinsicWidth.takeIf { it > 0 } ?: fallbackSizePx, + height = drawable.intrinsicHeight.takeIf { it > 0 } ?: fallbackSizePx, + config = Bitmap.Config.ARGB_8888, + ) + val encodedBytes = ByteArrayOutputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 92, output) + output.toByteArray() + } + ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = encodedBytes, + config = ArtworkTransportSanitizer.WIDGET_CONFIG, + ) + }.getOrElse { error -> + Timber.tag("WidgetArtworkResolver").w(error, "Artwork read failed via Coil for uri=%s", uri) + null + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 7f1d935994..4953534951 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -530,6 +530,21 @@ object AppModule { .build() } + /** + * Shared OkHttpClient for AI provider HTTP calls. + * Connection pooling is reused across all AI client instances. + */ + @Provides + @Singleton + @AiOkHttpClient + fun provideAiOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + } + /** * Provee una instancia singleton de Retrofit para la API de LRCLIB. */ diff --git a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt index d0207b1997..e978cd7ce8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt @@ -29,3 +29,10 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for shared AI OkHttpClient instance. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AiOkHttpClient diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt index 9787826e8f..7b6ddc37ad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt @@ -513,8 +513,11 @@ fun LyricsSheet( immersiveMode = false } - LaunchedEffect(currentSong, lyrics, isLoadingLyrics) { - if (currentSong != null && lyrics == null && !isLoadingLyrics) { + LaunchedEffect(currentSong, lyrics, isLoadingLyrics, lyricsSearchUiState) { + if (lyricsSearchUiState is LyricsSearchUiState.Success) { + // Search completed successfully — close the dialog + showFetchLyricsDialog = false + } else if (currentSong != null && lyrics == null && !isLoadingLyrics) { // Only show dialog if reset was not just triggered if (!wasResetTriggered) { showFetchLyricsDialog = true diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt index e1af93fbcc..e08e975844 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCreationDialogs.kt @@ -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 @@ -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 @@ -328,6 +330,7 @@ fun CreateAiPlaylistDialog( ) { CreateAiPlaylistContent( isGenerating = isGenerating, + status = status, error = error, onDismiss = onDismiss, onGenerate = onGenerate @@ -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 @@ -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( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 8a30f6f41d..336588bda0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -378,7 +378,6 @@ fun FullPlayerContent( if (showFetchLyricsDialog) { showFetchLyricsDialog = false showLyricsSheet = true - playerViewModel.resetLyricsSearchState() } } is LyricsSearchUiState.Error -> { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt index fb04d8a3d8..9bb1eae421 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/LibraryActionRow.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.res.stringResource val defaultShape = RoundedCornerShape(26.dp) // Fallback shape val genHeight = 42.dp +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryActionRow( onMainActionClick: () -> Unit, @@ -352,7 +353,6 @@ fun LibraryActionRow( } val tooltipState = rememberTooltipState() - @OptIn(ExperimentalMaterial3Api::class) TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt index e59c3d7a8c..5a3d1c7892 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt @@ -9,6 +9,8 @@ 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.QueueMusic +import androidx.compose.material.icons.rounded.Tune import androidx.compose.ui.graphics.vector.ImageVector import com.theveloper.pixelplay.R @@ -31,11 +33,23 @@ enum class SettingsCategory( subtitleRes = R.string.settings_category_appearance_subtitle, icon = Icons.Rounded.Palette ), + LYRICS( + id = "lyrics", + titleRes = R.string.settings_category_lyrics_title, + subtitleRes = R.string.settings_category_lyrics_subtitle, + icon = Icons.Rounded.QueueMusic + ), PLAYBACK( id = "playback", titleRes = R.string.settings_category_playback_title, subtitleRes = R.string.settings_category_playback_subtitle, - icon = Icons.Rounded.MusicNote // Using MusicNote again or maybe PlayCircle if available + icon = Icons.Rounded.MusicNote + ), + EQUALIZER( + id = "equalizer", + titleRes = R.string.settings_category_equalizer_title, + subtitleRes = R.string.settings_category_equalizer_subtitle, + icon = Icons.Rounded.GraphicEq ), BEHAVIOR( id = "behavior", @@ -49,6 +63,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, @@ -61,17 +81,11 @@ enum class SettingsCategory( subtitleRes = R.string.settings_category_developer_subtitle, icon = Icons.Rounded.DeveloperMode ), - EQUALIZER( - id = "equalizer", - titleRes = R.string.settings_category_equalizer_title, - subtitleRes = R.string.settings_category_equalizer_subtitle, - icon = Icons.Rounded.GraphicEq - ), DEVICE_CAPABILITIES( id = "device_capabilities", titleRes = R.string.settings_category_device_capabilities_title, subtitleRes = R.string.settings_category_device_capabilities_subtitle, - icon = Icons.Rounded.DeveloperBoard // Placeholder, maybe Memory or SettingsInputComponent + icon = Icons.Rounded.DeveloperBoard ), ABOUT( id = "about", @@ -83,4 +97,6 @@ enum class SettingsCategory( companion object { fun fromId(id: String): SettingsCategory? = entries.find { it.id == id } } + + } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt index 8c9a9b17fa..97933c5dd1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AiUsageComponents.kt @@ -49,6 +49,7 @@ fun AiUsageDateHeader(date: String) { } @Composable +@Suppress("NonObservableLocale") fun AiUsageLogItem( usage: AiUsageEntity ) { @@ -178,6 +179,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() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e9..e09c1d120d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -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) } @@ -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) @@ -1800,6 +1801,7 @@ fun LibraryScreen( CreateAiPlaylistDialog( visible = showCreateAiPlaylistDialog && hasActiveAiProviderApiKey, isGenerating = isGeneratingAiPlaylist, + status = aiStatus, error = aiError, onDismiss = { showCreateAiPlaylistDialog = false diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index eab38137e0..2831b36c1a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -140,8 +140,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -151,11 +150,6 @@ import androidx.compose.ui.res.stringResource private const val MAX_ALBUM_MULTI_SELECTION = 6 -private data class SearchUiSlice( - val selectedSearchFilter: SearchFilterType = SearchFilterType.ALL, - val searchResults: ImmutableList = persistentListOf() -) - @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -181,7 +175,7 @@ fun SearchScreen( val multiSelectionState = playerViewModel.multiSelectionStateHolder val selectedSongs by multiSelectionState.selectedSongs.collectAsStateWithLifecycle() val isSongSelectionMode by multiSelectionState.isSelectionMode.collectAsStateWithLifecycle() - val selectedSongIds by multiSelectionState.selectedSongIds.collectAsStateWithLifecycle() + val selectedSongIds = remember(selectedSongs) { selectedSongs.map { it.id }.toSet() } var showMultiSelectionSheet by remember { mutableStateOf(false) } // Multi-selection state for albums @@ -194,7 +188,7 @@ fun SearchScreen( val playlistSelectionState = playerViewModel.playlistSelectionStateHolder val selectedPlaylists by playlistSelectionState.selectedPlaylists.collectAsStateWithLifecycle() val isPlaylistSelectionMode by playlistSelectionState.isSelectionMode.collectAsStateWithLifecycle() - val selectedPlaylistIds by playlistSelectionState.selectedPlaylistIds.collectAsStateWithLifecycle() + val selectedPlaylistIds = remember(selectedPlaylists) { selectedPlaylists.map { it.id }.toSet() } var showPlaylistMultiSelectionSheet by remember { mutableStateOf(false) } // Multi-selection state for genres @@ -224,18 +218,17 @@ fun SearchScreen( multiSelectionState.toggleSelection(song) } } - val searchUiState by remember(playerViewModel) { - playerViewModel.playerUiState - .map { uiState -> - SearchUiSlice( - selectedSearchFilter = uiState.selectedSearchFilter, - searchResults = uiState.searchResults - ) - } - .distinctUntilChanged() - }.collectAsStateWithLifecycle(initialValue = SearchUiSlice()) - val currentFilter = searchUiState.selectedSearchFilter - val genres by playerViewModel.genres.collectAsStateWithLifecycle() + val searchResults by playerViewModel.searchResults.collectAsStateWithLifecycle() + val currentFilter by playerViewModel.selectedSearchFilter.collectAsStateWithLifecycle() + + // Defer genre loading — only fetch when the genre section is about to be shown + var genres by remember { mutableStateOf>(persistentListOf()) } + val showGenreBrowse by remember(searchQuery) { derivedStateOf { searchQuery.isBlank() } } + LaunchedEffect(showGenreBrowse) { + if (showGenreBrowse) { + genres = playerViewModel.genres.first() + } + } val stablePlayerState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() val favoriteSongIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() @@ -257,9 +250,10 @@ fun SearchScreen( // Search debouncing is centralized in SearchStateHolder. LaunchedEffect(searchQuery, currentFilter) { - playerViewModel.performSearch(searchQuery) + if (searchQuery.isNotBlank()) { + playerViewModel.performSearch(searchQuery) + } } - val searchResults = searchUiState.searchResults val handleSongMoreOptionsClick: (Song) -> Unit = { song -> playerViewModel.selectSongForInfo(song) showSongInfoBottomSheet = true @@ -416,7 +410,6 @@ fun SearchScreen( } } - val showGenreBrowse by remember(searchQuery) { derivedStateOf { searchQuery.isBlank() } } AnimatedContent( targetState = showGenreBrowse, transitionSpec = { @@ -587,6 +580,7 @@ fun SearchScreen( }, currentPlayingSongId = stablePlayerState.currentSong?.id, isPlaying = stablePlayerState.isPlaying, + isShuffleEnabled = stablePlayerState.isShuffleEnabled, onSongMoreOptionsClick = handleSongMoreOptionsClick, navController = navController, isSelectionMode = isSongSelectionMode, @@ -1076,6 +1070,7 @@ fun SearchResultsList( onItemSelected: () -> Unit, currentPlayingSongId: String?, isPlaying: Boolean, + isShuffleEnabled: Boolean = false, onSongMoreOptionsClick: (Song) -> Unit, navController: NavHostController, isSelectionMode: Boolean = false, @@ -1094,7 +1089,6 @@ fun SearchResultsList( getPlaylistSelectionIndex: (String) -> Int? = { null } ) { val localDensity = LocalDensity.current - val playerStableState by playerViewModel.stablePlayerState.collectAsStateWithLifecycle() if (results.isEmpty()) { Box( @@ -1301,7 +1295,7 @@ fun SearchResultsList( songs.first(), item.playlist.name ) - if (playerStableState.isShuffleEnabled) playerViewModel.toggleShuffle() + if (isShuffleEnabled) playerViewModel.toggleShuffle() } else { playerViewModel.sendToast("Empty playlist") } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index dd1672dbc6..d23d652ca1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -508,33 +508,6 @@ fun SettingsCategoryScreen( ) } - SettingsSubsection( - title = stringResource(R.string.settings_lyrics_management_section), - addBottomSpace = false - ) { - ThemeSelectorItem( - label = stringResource(R.string.settings_lyrics_source_priority_title), - description = stringResource(R.string.settings_lyrics_source_priority_subtitle), - options = mapOf( - LyricsSourcePreference.EMBEDDED_FIRST.name to stringResource(R.string.settings_lyrics_embedded_first), - LyricsSourcePreference.API_FIRST.name to stringResource(R.string.settings_lyrics_online_first), - LyricsSourcePreference.LOCAL_FIRST.name to stringResource(R.string.settings_lyrics_local_first) - ), - selectedKey = uiState.lyricsSourcePreference.name, - onSelectionChanged = { key -> - settingsViewModel.setLyricsSourcePreference( - LyricsSourcePreference.fromName(key) - ) - }, - leadingIcon = { Icon(painterResource(R.drawable.rounded_lyrics_24), null, tint = MaterialTheme.colorScheme.secondary) } - ) - SettingsItem( - title = stringResource(R.string.settings_reset_imported_lyrics_title), - subtitle = stringResource(R.string.settings_reset_imported_lyrics_subtitle), - leadingIcon = { Icon(Icons.Outlined.ClearAll, null, tint = MaterialTheme.colorScheme.secondary) }, - onClick = { showClearLyricsDialog = true } - ) - } } SettingsCategory.APPEARANCE -> { val useSmoothCorners by settingsViewModel.useSmoothCorners.collectAsStateWithLifecycle() @@ -679,32 +652,6 @@ fun SettingsCategoryScreen( ) } - SettingsSubsection(title = stringResource(R.string.settings_lyrics_screen_section)) { - SwitchSettingItem( - title = stringResource(R.string.settings_immersive_lyrics_title), - subtitle = stringResource(R.string.settings_immersive_lyrics_subtitle), - checked = uiState.immersiveLyricsEnabled, - onCheckedChange = { settingsViewModel.setImmersiveLyricsEnabled(it) }, - leadingIcon = { Icon(painterResource(R.drawable.rounded_lyrics_24), null, tint = MaterialTheme.colorScheme.secondary) } - ) - - if (uiState.immersiveLyricsEnabled) { - ThemeSelectorItem( - label = stringResource(R.string.settings_auto_hide_delay_title), - description = stringResource(R.string.settings_auto_hide_delay_subtitle), - options = mapOf( - "3000" to stringResource(R.string.settings_auto_hide_delay_3s), - "4000" to stringResource(R.string.settings_auto_hide_delay_4s), - "5000" to stringResource(R.string.settings_auto_hide_delay_5s), - "6000" to stringResource(R.string.settings_auto_hide_delay_6s) - ), - selectedKey = uiState.immersiveLyricsTimeout.toString(), - onSelectionChanged = { settingsViewModel.setImmersiveLyricsTimeout(it.toLong()) }, - leadingIcon = { Icon(Icons.Rounded.Timer, null, tint = MaterialTheme.colorScheme.secondary) } - ) - } - } - SettingsSubsection( title = stringResource(R.string.settings_app_navigation_section), addBottomSpace = false @@ -734,6 +681,61 @@ fun SettingsCategoryScreen( ) } } + SettingsCategory.LYRICS -> { + SettingsSubsection( + title = stringResource(R.string.settings_lyrics_management_section), + addBottomSpace = false + ) { + ThemeSelectorItem( + label = stringResource(R.string.settings_lyrics_source_priority_title), + description = stringResource(R.string.settings_lyrics_source_priority_subtitle), + options = mapOf( + LyricsSourcePreference.EMBEDDED_FIRST.name to stringResource(R.string.settings_lyrics_embedded_first), + LyricsSourcePreference.API_FIRST.name to stringResource(R.string.settings_lyrics_online_first), + LyricsSourcePreference.LOCAL_FIRST.name to stringResource(R.string.settings_lyrics_local_first) + ), + selectedKey = uiState.lyricsSourcePreference.name, + onSelectionChanged = { key -> + settingsViewModel.setLyricsSourcePreference( + LyricsSourcePreference.fromName(key) + ) + }, + leadingIcon = { Icon(painterResource(R.drawable.rounded_lyrics_24), null, tint = MaterialTheme.colorScheme.secondary) } + ) + SettingsItem( + title = stringResource(R.string.settings_reset_imported_lyrics_title), + subtitle = stringResource(R.string.settings_reset_imported_lyrics_subtitle), + leadingIcon = { Icon(Icons.Outlined.ClearAll, null, tint = MaterialTheme.colorScheme.secondary) }, + onClick = { showClearLyricsDialog = true } + ) + } + + SettingsSubsection(title = stringResource(R.string.settings_lyrics_screen_section)) { + SwitchSettingItem( + title = stringResource(R.string.settings_immersive_lyrics_title), + subtitle = stringResource(R.string.settings_immersive_lyrics_subtitle), + checked = uiState.immersiveLyricsEnabled, + onCheckedChange = { settingsViewModel.setImmersiveLyricsEnabled(it) }, + leadingIcon = { Icon(painterResource(R.drawable.rounded_lyrics_24), null, tint = MaterialTheme.colorScheme.secondary) } + ) + + if (uiState.immersiveLyricsEnabled) { + ThemeSelectorItem( + label = stringResource(R.string.settings_auto_hide_delay_title), + description = stringResource(R.string.settings_auto_hide_delay_subtitle), + options = mapOf( + "3000" to stringResource(R.string.settings_auto_hide_delay_3s), + "4000" to stringResource(R.string.settings_auto_hide_delay_4s), + "5000" to stringResource(R.string.settings_auto_hide_delay_5s), + "6000" to stringResource(R.string.settings_auto_hide_delay_6s) + ), + selectedKey = uiState.immersiveLyricsTimeout.toString(), + onSelectionChanged = { settingsViewModel.setImmersiveLyricsTimeout(it.toLong()) }, + leadingIcon = { Icon(Icons.Rounded.Timer, null, tint = MaterialTheme.colorScheme.secondary) } + ) + } + } + } SettingsCategory.PLAYBACK -> { SettingsSubsection(title = stringResource(R.string.settings_background_playback_section)) { ThemeSelectorItem( @@ -917,16 +919,16 @@ fun SettingsCategoryScreen( } SettingsCategory.AI_INTEGRATION -> { val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) - val currentCustomBaseUrl by settingsViewModel.customBaseUrl.collectAsStateWithLifecycle() + val currentAiBaseUrl by settingsViewModel.currentAiBaseUrl.collectAsStateWithLifecycle() // AI Provider Selection SettingsSubsection(title = stringResource(R.string.settings_ai_provider_section)) { - ThemeSelectorItem( + SearchableProviderSelector( label = stringResource(R.string.settings_ai_provider_title), description = stringResource(R.string.settings_ai_provider_subtitle), - options = com.theveloper.pixelplay.data.ai.provider.AiProvider.entries.associate { it.name to it.displayName }, - selectedKey = aiProvider, - onSelectionChanged = { settingsViewModel.onAiProviderChange(it) }, + providers = com.theveloper.pixelplay.data.ai.provider.AiProvider.entries, + selectedProvider = aiProvider, + onProviderSelected = { settingsViewModel.onAiProviderChange(it) }, leadingIcon = { Icon(Icons.Rounded.Science, null, tint = MaterialTheme.colorScheme.secondary) } ) SwitchSettingItem( @@ -1029,14 +1031,29 @@ fun SettingsCategoryScreen( if (provider.hasConfigurableUrl) { SettingsSubsection(title = "API Base URL") { AiApiKeyItem( - apiKey = currentCustomBaseUrl, - onApiKeySave = { settingsViewModel.onCustomBaseUrlChange(it) }, + apiKey = currentAiBaseUrl, + onApiKeySave = { settingsViewModel.onBaseUrlChange(it) }, title = "Base URL", subtitle = "e.g. https://api.example.com/v1" ) } } + Spacer(modifier = Modifier.height(16.dp)) + + AiUsageReportSection(settingsViewModel = settingsViewModel) + } + SettingsCategory.GENERATION_PARAMETERS -> { + val aiTemperature by settingsViewModel.aiTemperature.collectAsStateWithLifecycle() + val aiTopP by settingsViewModel.aiTopP.collectAsStateWithLifecycle() + val aiTopK by settingsViewModel.aiTopK.collectAsStateWithLifecycle() + val aiMaxTokens by settingsViewModel.aiMaxTokens.collectAsStateWithLifecycle() + val aiPresencePenalty by settingsViewModel.aiPresencePenalty.collectAsStateWithLifecycle() + val aiFrequencyPenalty by settingsViewModel.aiFrequencyPenalty.collectAsStateWithLifecycle() + val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() + val aiDigestMode by settingsViewModel.aiDigestMode.collectAsStateWithLifecycle() + val aiIncludeExtendedFields by settingsViewModel.aiIncludeExtendedFields.collectAsStateWithLifecycle() + // Prompt Behavior Section SettingsSubsection( title = stringResource(R.string.settings_prompt_behavior_section), @@ -1056,7 +1073,7 @@ fun SettingsCategoryScreen( SettingsSubsection(title = "Generation Parameters") { SliderSettingsItem( label = "Temperature", - value = settingsViewModel.aiTemperature.collectAsStateWithLifecycle().value, + value = aiTemperature, valueRange = 0.0f..2.0f, steps = 20, onValueChange = { settingsViewModel.onAiTemperatureChange(it) }, @@ -1070,7 +1087,7 @@ fun SettingsCategoryScreen( ) SliderSettingsItem( label = "Top P", - value = settingsViewModel.aiTopP.collectAsStateWithLifecycle().value, + value = aiTopP, valueRange = 0.0f..1.0f, steps = 20, onValueChange = { settingsViewModel.onAiTopPChange(it) }, @@ -1084,7 +1101,7 @@ fun SettingsCategoryScreen( ) SliderSettingsItem( label = "Top K", - value = settingsViewModel.aiTopK.collectAsStateWithLifecycle().value.toFloat(), + value = aiTopK.toFloat(), valueRange = 1f..100f, steps = 99, onValueChange = { settingsViewModel.onAiTopKChange(it.toInt()) }, @@ -1098,7 +1115,7 @@ fun SettingsCategoryScreen( ) SliderSettingsItem( label = "Max Output Tokens", - value = settingsViewModel.aiMaxTokens.collectAsStateWithLifecycle().value.toFloat(), + value = aiMaxTokens.toFloat(), valueRange = 128f..8192f, steps = 63, onValueChange = { settingsViewModel.onAiMaxTokensChange(it.toInt()) }, @@ -1112,7 +1129,7 @@ fun SettingsCategoryScreen( ) SliderSettingsItem( label = "Presence Penalty", - value = settingsViewModel.aiPresencePenalty.collectAsStateWithLifecycle().value, + value = aiPresencePenalty, valueRange = -2.0f..2.0f, steps = 40, onValueChange = { settingsViewModel.onAiPresencePenaltyChange(it) }, @@ -1126,7 +1143,7 @@ fun SettingsCategoryScreen( ) SliderSettingsItem( label = "Frequency Penalty", - value = settingsViewModel.aiFrequencyPenalty.collectAsStateWithLifecycle().value, + value = aiFrequencyPenalty, valueRange = -2.0f..2.0f, steps = 40, onValueChange = { settingsViewModel.onAiFrequencyPenaltyChange(it) }, @@ -1140,9 +1157,10 @@ fun SettingsCategoryScreen( ) } + Spacer(modifier = Modifier.height(16.dp)) + // Song Data Configuration Section SettingsSubsection(title = "Song Data Configuration") { - val aiSampleSize by settingsViewModel.aiSampleSize.collectAsStateWithLifecycle() SliderSettingsItem( label = "Sample Size", value = aiSampleSize.toFloat(), @@ -1161,7 +1179,7 @@ fun SettingsCategoryScreen( label = "Digest Detail", description = "Controls how much listening history data is included", options = mapOf("safe" to "Concise (faster)", "full" to "Full (better quality)"), - selectedKey = settingsViewModel.aiDigestMode.collectAsStateWithLifecycle().value, + selectedKey = aiDigestMode, onSelectionChanged = { settingsViewModel.onAiDigestModeChange(it) }, leadingIcon = { Icon( @@ -1174,7 +1192,7 @@ fun SettingsCategoryScreen( SwitchSettingItem( title = "Extended Song Fields", subtitle = "Include album, year, and genre info in song data sent to AI", - checked = settingsViewModel.aiIncludeExtendedFields.collectAsStateWithLifecycle().value, + checked = aiIncludeExtendedFields, onCheckedChange = { settingsViewModel.onAiIncludeExtendedFieldsChange(it) }, leadingIcon = { Icon( @@ -1188,103 +1206,8 @@ fun SettingsCategoryScreen( Spacer(modifier = Modifier.height(16.dp)) - SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { - val recentAiUsage by settingsViewModel.recentAiUsage.collectAsStateWithLifecycle() - val totalPromptTokens by settingsViewModel.totalPromptTokens.collectAsStateWithLifecycle() - val totalOutputTokens by settingsViewModel.totalOutputTokens.collectAsStateWithLifecycle() - val totalThoughtTokens by settingsViewModel.totalThoughtTokens.collectAsStateWithLifecycle() - - val totalTokens = totalPromptTokens + totalOutputTokens + totalThoughtTokens - val totalTokStr = String.format(Locale.US, "%,d", totalTokens) - val promptTokStr = String.format(Locale.US, "%,d", totalPromptTokens) - val outputTokStr = String.format(Locale.US, "%,d", totalOutputTokens) - val thoughtTokStr = String.format(Locale.US, "%,d", totalThoughtTokens) - - ActionSettingsItem( - title = stringResource(R.string.settings_total_consumption_title), - subtitle = stringResource( - R.string.settings_ai_usage_tokens_subtitle, - totalTokStr, - promptTokStr, - outputTokStr, - thoughtTokStr - ), - icon = { - Icon( - painter = painterResource(R.drawable.rounded_monitoring_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary - ) - }, - primaryActionLabel = stringResource(R.string.settings_ai_clear_logs), - onPrimaryAction = { settingsViewModel.clearAiUsageData() } - ) - - if (recentAiUsage.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - var expanded by remember { mutableStateOf(false) } - val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f) - - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - color = Color.Transparent - ) { - Row( - modifier = Modifier.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.rounded_monitoring_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.settings_ai_activity_log_title, recentAiUsage.size), - style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), - color = MaterialTheme.colorScheme.onSurface - ) - } - Icon( - imageVector = Icons.Rounded.ExpandMore, - contentDescription = if (expanded) stringResource(R.string.settings_ai_hide_logs) else stringResource(R.string.settings_ai_show_logs), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.rotate(rotation) - ) - } - } - - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 8.dp) - ) { - val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) - val groupedUsage = recentAiUsage.groupBy { - dateFormat.format(Date(it.timestamp)) - } + AiUsageReportSection(settingsViewModel = settingsViewModel) - groupedUsage.forEach { (date, items) -> - AiUsageDateHeader(date = date) - items.forEach { usage -> - AiUsageLogItem(usage = usage) - } - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } - } } SettingsCategory.BACKUP_RESTORE -> { if (!uiState.backupInfoDismissed) { @@ -2927,3 +2850,105 @@ private fun SettingsSubsection( Spacer(modifier = Modifier.height(10.dp)) } } + +@Composable +private fun AiUsageReportSection(settingsViewModel: SettingsViewModel) { + SettingsSubsection(title = stringResource(R.string.settings_ai_usage_report_section)) { + val recentAiUsage by settingsViewModel.recentAiUsage.collectAsStateWithLifecycle() + val totalPromptTokens by settingsViewModel.totalPromptTokens.collectAsStateWithLifecycle() + val totalOutputTokens by settingsViewModel.totalOutputTokens.collectAsStateWithLifecycle() + val totalThoughtTokens by settingsViewModel.totalThoughtTokens.collectAsStateWithLifecycle() + + val totalTokens = totalPromptTokens + totalOutputTokens + totalThoughtTokens + val totalTokStr = String.format(Locale.US, "%,d", totalTokens) + val promptTokStr = String.format(Locale.US, "%,d", totalPromptTokens) + val outputTokStr = String.format(Locale.US, "%,d", totalOutputTokens) + val thoughtTokStr = String.format(Locale.US, "%,d", totalThoughtTokens) + + ActionSettingsItem( + title = stringResource(R.string.settings_total_consumption_title), + subtitle = stringResource( + R.string.settings_ai_usage_tokens_subtitle, + totalTokStr, + promptTokStr, + outputTokStr, + thoughtTokStr + ), + icon = { + Icon( + painter = painterResource(R.drawable.rounded_monitoring_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + }, + primaryActionLabel = stringResource(R.string.settings_ai_clear_logs), + onPrimaryAction = { settingsViewModel.clearAiUsageData() } + ) + + if (recentAiUsage.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + var expanded by remember { mutableStateOf(false) } + val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + color = Color.Transparent + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.rounded_monitoring_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.settings_ai_activity_log_title, recentAiUsage.size), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), + color = MaterialTheme.colorScheme.onSurface + ) + } + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.settings_ai_hide_logs) else stringResource(R.string.settings_ai_show_logs), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp) + ) { + @Suppress("NonObservableLocale") + val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) + val groupedUsage = recentAiUsage.groupBy { + dateFormat.format(Date(it.timestamp)) + } + + groupedUsage.forEach { (date, items) -> + AiUsageDateHeader(date = date) + items.forEach { usage -> + AiUsageLogItem(usage = usage) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt index bc5dbb3293..0f9ca6572e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsComponents.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.theveloper.pixelplay.presentation.screens import androidx.compose.animation.AnimatedContent @@ -551,6 +553,184 @@ fun SearchableModelSelector( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchableProviderSelector( + label: String, + description: String, + providers: List, + selectedProvider: String, + onProviderSelected: (String) -> Unit, + leadingIcon: @Composable () -> Unit +) { + var showSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val selectedDisplayName = providers.find { it.name == selectedProvider }?.displayName + ?: selectedProvider + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { showSheet = true } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { leadingIcon() } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(10.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = CircleShape, + modifier = Modifier.align(Alignment.Start) + ) { + Text( + text = selectedDisplayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + } + + if (showSheet) { + ModalBottomSheet( + onDismissRequest = { + showSheet = false + searchQuery = "" + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = label, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + fontWeight = FontWeight.Bold + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search providers...") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val filteredProviders = remember(providers, searchQuery) { + if (searchQuery.isBlank()) providers + else providers.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.displayName.contains(searchQuery, ignoreCase = true) || + it.description.contains(searchQuery, ignoreCase = true) + } + } + + Text( + text = "${filteredProviders.size} provider${if (filteredProviders.size != 1) "s" else ""} available", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) + ) + + LazyColumn( + modifier = Modifier + .padding(horizontal = 16.dp) + .heightIn(max = 400.dp) + ) { + items(filteredProviders, key = { it.name }) { provider -> + val isSelected = provider.name == selectedProvider + Surface( + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onProviderSelected(provider.name) + showSheet = false + searchQuery = "" + } + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = provider.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + ) + Text( + text = provider.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + } +} + @Composable fun SliderSettingsItem( label: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index fa40ecb265..2acbf769a9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -480,9 +480,11 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair when (category) { SettingsCategory.LIBRARY -> Color(0xFF004A77) to Color(0xFFC2E7FF) SettingsCategory.APPEARANCE -> Color(0xFF7D5260) to Color(0xFFFFD8E4) + SettingsCategory.LYRICS -> Color(0xFF4A1A6E) to Color(0xFFE8D0FF) SettingsCategory.PLAYBACK -> Color(0xFF633B48) to Color(0xFFFFD8EC) SettingsCategory.BEHAVIOR -> Color(0xFF3E4C63) to Color(0xFFD7E3FF) SettingsCategory.AI_INTEGRATION -> Color(0xFF004F58) to Color(0xFF88FAFF) + SettingsCategory.GENERATION_PARAMETERS -> Color(0xFF4A256B) to Color(0xFFE8B0FF) SettingsCategory.BACKUP_RESTORE -> Color(0xFF3B4869) to Color(0xFFD9E2FF) SettingsCategory.DEVELOPER -> Color(0xFF324F34) to Color(0xFFCBEFD0) SettingsCategory.EQUALIZER -> Color(0xFF6E4E13) to Color(0xFFFFDEAC) @@ -493,9 +495,11 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair when (category) { SettingsCategory.LIBRARY -> Color(0xFFD7E3FF) to Color(0xFF005AC1) SettingsCategory.APPEARANCE -> Color(0xFFFFD8E4) to Color(0xFF631835) + SettingsCategory.LYRICS -> Color(0xFFF0E0FF) to Color(0xFF4A1A6E) SettingsCategory.PLAYBACK -> Color(0xFFFFD8EC) to Color(0xFF631B4B) SettingsCategory.BEHAVIOR -> Color(0xFFD7E3FF) to Color(0xFF253347) SettingsCategory.AI_INTEGRATION -> Color(0xFFCCE8EA) to Color(0xFF004F58) + SettingsCategory.GENERATION_PARAMETERS -> Color(0xFFE8B0FF) to Color(0xFF3B1A53) SettingsCategory.BACKUP_RESTORE -> Color(0xFFD9E2FF) to Color(0xFF27304E) SettingsCategory.DEVELOPER -> Color(0xFFCBEFD0) to Color(0xFF042106) SettingsCategory.EQUALIZER -> Color(0xFFFFDEAC) to Color(0xFF281900) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index ad68cea838..3dee09de7c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt @@ -6,10 +6,12 @@ import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.DailyMixManager import com.theveloper.pixelplay.data.ai.AiNotificationManager import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator +import com.theveloper.pixelplay.data.ai.AiResponseCleaner import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.provider.AiProviderException import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.utils.MultiLangRomanizer import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -286,39 +289,94 @@ class AiStateHolder @Inject constructor( suspend fun translateLyrics(lyricsText: String): Result { return try { - val targetLanguage = context.resources.configuration.locales[0].displayLanguage - val prompt = """ -Translate song lyrics into $targetLanguage. + val targetLanguage = context.resources.configuration.locales[0] + .getDisplayLanguage(Locale.US) + val sourceLanguage = detectLyricsLanguage(lyricsText) + val hasTimestamps = lyricsText.contains(Regex("\\[\\d{1,2}:\\d{2}")) + val taskPrefix = if (sourceLanguage != null) { + "Translate these $sourceLanguage song lyrics into $targetLanguage." + } else { + "Translate these song lyrics into $targetLanguage. Detect the source language from the lyrics content." + } + val formatSection = if (hasTimestamps) { + """ + +For SYNCED lyrics — output TWO lines per original line: +[original timestamp] original text +[same timestamp] translated text + +Preserve ALL timestamps [mm:ss.xx] exactly — never modify, merge, or drop them. + + """.trimIndent() + } else { + """ + +For PLAIN lyrics (no timestamps) — output TWO lines per original line: +original text +translated text + +Keep the same line order as the original. + + """.trimIndent() + } + val xmlPrompt = """ +$taskPrefix -- Preserve ALL timestamps [mm:ss.xx] exactly — never modify, merge, or drop them. -- Output TWO lines per original line: the original, then the translation with the same timestamp. +- Output TWO lines per original line: the original, then the translation. - NEVER add explanations, labels, numbering, section headers, or formatting. - NEVER remove, merge, split, or reorder lines. +- Translate ALL lines completely — do not skip or truncate any part. - If lyrics are ALREADY mostly in $targetLanguage, output ONLY: ALREADY_IN_TARGET_LANGUAGE - -[original timestamp] original text -[same timestamp] translated text - +$formatSection $lyricsText """.trimIndent() - + val response = aiHandler.generateContent( - prompt = prompt, - type = AiSystemPromptType.GENERAL, + prompt = xmlPrompt, + type = AiSystemPromptType.LYRICS, temperature = 0.1f ) - Result.success(response) + val cleaned = AiResponseCleaner.cleanTextResponse(response) + Result.success(cleaned) } catch (e: Exception) { Result.failure(e) } } + private val timestampRegex = Regex("\\[\\d{1,2}:\\d{2}(?:[.:]\\d{1,3})?]") + + private fun detectLyricsLanguage(lyricsText: String): String? { + val sample = timestampRegex.replace(lyricsText, "").trim().take(200) + if (sample.isBlank()) return null + + return when { + MultiLangRomanizer.isJapanese(sample) -> "Japanese" + MultiLangRomanizer.isKorean(sample) -> "Korean" + MultiLangRomanizer.isChinese(sample) -> "Chinese" + MultiLangRomanizer.isThai(sample) -> "Thai" + MultiLangRomanizer.isArabic(sample) -> "Arabic" + MultiLangRomanizer.isGreek(sample) -> "Greek" + MultiLangRomanizer.isHebrew(sample) -> "Hebrew" + MultiLangRomanizer.isHindi(sample) -> "Hindi" + MultiLangRomanizer.isPunjabi(sample) -> "Punjabi" + MultiLangRomanizer.isCyrillic(sample) -> { + when { + MultiLangRomanizer.isRussian(sample) -> "Russian" + MultiLangRomanizer.isUkrainian(sample) -> "Ukrainian" + else -> null + } + } + MultiLangRomanizer.isVietnamese(sample) -> "Vietnamese" + else -> null + } + } + fun onCleared() { scope = null allSongsProvider = null diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt index 22fb7d311f..3515c7de8a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt @@ -257,23 +257,21 @@ class LibraryStateHolder @Inject constructor( songsJob = scope?.launch { _isLoadingLibrary.value = true musicRepository.getAudioFiles().conflate().collect { songs -> - // Process heavy list conversions on Default dispatcher to avoid blocking UI val immutableSongs = withContext(Dispatchers.Default) { songs.toImmutableList() } val songsMap = withContext(Dispatchers.Default) { songs.associateBy { it.id } } _allSongs.value = immutableSongs _allSongsById.value = songsMap - // When the repository emits a new list (triggered by directory changes), - // we update our state and re-apply current sorting. - // Apply sort to the new data sortSongs(_currentSongSortOption.value, persist = false) _isLoadingLibrary.value = false } } + // Albums, artists, and folders load sequentially after songs to avoid + // I/O contention on slower storage (eMMC on Redmi vs UFS on Samsung). albumsJob = scope?.launch { - _isLoadingCategories.value = true + songsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( effectiveStorageFilter, @@ -287,12 +285,11 @@ class LibraryStateHolder @Inject constructor( sortAlbumsList(albums, _currentAlbumSortOption.value).toImmutableList() } _albums.value = sortedAlbums - _isLoadingCategories.value = false } } artistsJob = scope?.launch { - _isLoadingCategories.value = true + albumsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getArtists(filter) @@ -301,11 +298,11 @@ class LibraryStateHolder @Inject constructor( sortArtistsList(artists, _currentArtistSortOption.value).toImmutableList() } _artists.value = sortedArtists - _isLoadingCategories.value = false } } foldersJob = scope?.launch { + artistsJob?.join() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getMusicFolders(effectiveFoldersStorageFilter(filter)) @@ -316,6 +313,16 @@ class LibraryStateHolder @Inject constructor( _musicFolders.value = sortedFolders } } + + // Single loading state: true when songs start, false when all sequential + // jobs (albums + artists) finish. This avoids flapping true→false→true + // as each sequential coroutine completes. + scope?.launch { + songsJob?.join() + _isLoadingCategories.value = true + foldersJob?.join() + _isLoadingCategories.value = false + } } // Deprecated imperative loaders - redirected to observer start diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt index 87985fd6eb..19ae900724 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt @@ -344,22 +344,16 @@ class LyricsStateHolder @Inject constructor( fun translateLyricsViaAi(currentSong: Song, lyricsObj: Lyrics?, cb: LyricsTranslationCallbacks) { val songId = currentSong.id.toLongOrNull() ?: return - if (lyricsObj?.synced != null) { - val hasValidTranslation = lyricsObj.synced.any { !it.translation.isNullOrBlank() } - if (hasValidTranslation) { - _messageEvents.tryEmit(cb.getString(R.string.lyrics_translate_already_translated)) - return - } - } - scope?.launch { _messageEvents.emit(cb.getString(R.string.lyrics_translate_progress)) + // Always read fresh lyrics from source — never rely on the potentially + // stale Lyrics object from UI state which may contain old translations. val rawLyrics = withContext(Dispatchers.IO) { - currentSong.lyrics?.takeIf { it.isNotBlank() } - ?: readLocalLyricsFile(currentSong) + readLocalLyricsFile(currentSong) ?: readEmbeddedLyricsFromFile(currentSong) ?: musicRepository.getStoredLyrics(currentSong)?.second + ?: currentSong.lyrics?.takeIf { it.isNotBlank() } } if (rawLyrics.isNullOrBlank()) { @@ -428,17 +422,18 @@ class LyricsStateHolder @Inject constructor( } private fun readEmbeddedLyricsFromFile(song: Song): String? { - song.lyrics - ?.trim() - ?.takeIf { it.isNotBlank() } - ?.let { return it } - - return runCatching { + val fileLyrics = runCatching { AudioMetadataReader.read(File(song.path)) ?.lyrics ?.trim() ?.takeIf { it.isNotBlank() } }.getOrNull() + + if (fileLyrics != null) return fileLyrics + + return song.lyrics + ?.trim() + ?.takeIf { it.isNotBlank() } } private fun readLocalLyricsFile(song: Song): String? { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 4ddadbedea..a3e9d36cf5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -45,6 +45,8 @@ import com.theveloper.pixelplay.data.model.Genre import com.theveloper.pixelplay.data.model.Lyrics import com.theveloper.pixelplay.data.model.LyricsSourcePreference import com.theveloper.pixelplay.data.model.SearchFilterType +import com.theveloper.pixelplay.data.model.SearchHistoryItem +import com.theveloper.pixelplay.data.model.SearchResultItem import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.toLibraryTabIdOrNull @@ -444,6 +446,11 @@ class PlayerViewModel @Inject constructor( val aiStatus: StateFlow = aiStateHolder.aiStatus val aiError: StateFlow = aiStateHolder.aiError + // Search state (direct access bypasses 40-field playerUiState) + val searchResults: StateFlow> = searchStateHolder.searchResults + val selectedSearchFilter: StateFlow = searchStateHolder.selectedSearchFilter + val searchHistory: StateFlow> = searchStateHolder.searchHistory + private val _selectedSongForInfo = MutableStateFlow(null) val selectedSongForInfo: StateFlow = _selectedSongForInfo.asStateFlow() @@ -536,14 +543,6 @@ class PlayerViewModel @Inject constructor( initialValue = false ) - val hasGeminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey - .map { it.isNotBlank() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false - ) - val fullPlayerLoadingTweaks: StateFlow = userPreferencesRepository.fullPlayerLoadingTweaksFlow .stateIn( scope = viewModelScope, @@ -646,7 +645,7 @@ class PlayerViewModel @Inject constructor( // Toast Events private val _toastEvents = MutableSharedFlow( - extraBufferCapacity = 1, + extraBufferCapacity = 5, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val toastEvents = _toastEvents.asSharedFlow() @@ -1140,13 +1139,6 @@ class PlayerViewModel @Inject constructor( initialValue = persistentListOf() ) - val songCountFlow: StateFlow = musicRepository.getSongCountFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 0 - ) - val hasCloudSongsFlow: StateFlow = musicRepository.getCloudSongCountFlow() .map { it > 0 } .distinctUntilChanged() @@ -1527,15 +1519,11 @@ class PlayerViewModel @Inject constructor( + // Deferred: legacy migrations run after init to avoid blocking first render viewModelScope.launch { + kotlinx.coroutines.delay(2000) userPreferencesRepository.migrateTabOrder() - } - - viewModelScope.launch { userPreferencesRepository.ensureLibrarySortDefaults() - } - - viewModelScope.launch { val legacyFavoriteIds = userPreferencesRepository.favoriteSongIdsFlow.first() if (legacyFavoriteIds.isNotEmpty()) { val roomFavoriteIds = musicRepository.getFavoriteSongIdsOnce() @@ -1677,8 +1665,12 @@ class PlayerViewModel @Inject constructor( // launchColorSchemeProcessor() - Handled by ThemeStateHolder and on-demand calls - loadPersistedDailyMix() - loadSearchHistory() + // Deferred: daily mix and search history load after first frame + viewModelScope.launch { + kotlinx.coroutines.delay(1500) + loadPersistedDailyMix() + loadSearchHistory() + } viewModelScope.launch { isSyncingStateFlow.collect { isSyncing -> @@ -1797,7 +1789,7 @@ class PlayerViewModel @Inject constructor( aiStatus = status, aiError = error ) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( showAiPlaylistSheet = snapshot.showAiPlaylistSheet, @@ -1820,7 +1812,7 @@ class PlayerViewModel @Inject constructor( libraryStateHolder.isLoadingCategories, ) { folders, loadingLibrary, loadingCategories -> Triple(folders, loadingLibrary, loadingCategories) - }.collect { (folders, loadingLibrary, loadingCategories) -> + }.distinctUntilChanged().collect { (folders, loadingLibrary, loadingCategories) -> _playerUiState.update { it.copy( musicFolders = folders, @@ -1841,7 +1833,7 @@ class PlayerViewModel @Inject constructor( libraryStateHolder.currentFavoriteSortOption, ) { songSort, albumSort, artistSort, folderSort, favoriteSort -> SortOptionsSnapshot(songSort, albumSort, artistSort, folderSort, favoriteSort) - }.collect { snapshot -> + }.distinctUntilChanged().collect { snapshot -> _playerUiState.update { it.copy( currentSongSortOption = snapshot.songSort, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index abba7eaceb..d8831fd9fc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -213,86 +213,6 @@ class SettingsViewModel @Inject constructor( .flatMapLatest { provider -> aiPreferencesRepository.getSystemPrompt(AiProvider.fromString(provider)) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - // Specific Provider StateFlows for UI Compatibility - val geminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val geminiModel: StateFlow = aiPreferencesRepository.geminiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val geminiSystemPrompt: StateFlow = aiPreferencesRepository.geminiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - - val deepseekApiKey: StateFlow = aiPreferencesRepository.deepseekApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val deepseekModel: StateFlow = aiPreferencesRepository.deepseekModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val deepseekSystemPrompt: StateFlow = aiPreferencesRepository.deepseekSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_DEEPSEEK_SYSTEM_PROMPT) - - val groqApiKey: StateFlow = aiPreferencesRepository.groqApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val groqModel: StateFlow = aiPreferencesRepository.groqModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val groqSystemPrompt: StateFlow = aiPreferencesRepository.groqSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_GROQ_SYSTEM_PROMPT) - - val mistralApiKey: StateFlow = aiPreferencesRepository.mistralApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val mistralModel: StateFlow = aiPreferencesRepository.mistralModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val mistralSystemPrompt: StateFlow = aiPreferencesRepository.mistralSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_MISTRAL_SYSTEM_PROMPT) - - val nvidiaApiKey: StateFlow = aiPreferencesRepository.nvidiaApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val nvidiaModel: StateFlow = aiPreferencesRepository.nvidiaModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val nvidiaSystemPrompt: StateFlow = aiPreferencesRepository.nvidiaSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_NVIDIA_SYSTEM_PROMPT) - - val kimiApiKey: StateFlow = aiPreferencesRepository.kimiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val kimiModel: StateFlow = aiPreferencesRepository.kimiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val kimiSystemPrompt: StateFlow = aiPreferencesRepository.kimiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_KIMI_SYSTEM_PROMPT) - - val glmApiKey: StateFlow = aiPreferencesRepository.glmApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val glmModel: StateFlow = aiPreferencesRepository.glmModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val glmSystemPrompt: StateFlow = aiPreferencesRepository.glmSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_GLM_SYSTEM_PROMPT) - - val openaiApiKey: StateFlow = aiPreferencesRepository.openaiApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openaiModel: StateFlow = aiPreferencesRepository.openaiModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openaiSystemPrompt: StateFlow = aiPreferencesRepository.openaiSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENAI_SYSTEM_PROMPT) - - val openrouterApiKey: StateFlow = aiPreferencesRepository.openrouterApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openrouterModel: StateFlow = aiPreferencesRepository.openrouterModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val openrouterSystemPrompt: StateFlow = aiPreferencesRepository.openrouterSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_OPENROUTER_SYSTEM_PROMPT) - - val ollamaApiKey: StateFlow = aiPreferencesRepository.ollamaApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val ollamaModel: StateFlow = aiPreferencesRepository.ollamaModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val ollamaSystemPrompt: StateFlow = aiPreferencesRepository.ollamaSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - - val customApiKey: StateFlow = aiPreferencesRepository.customApiKey - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val customModel: StateFlow = aiPreferencesRepository.customModel - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val customSystemPrompt: StateFlow = aiPreferencesRepository.customSystemPrompt - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AiPreferencesRepository.DEFAULT_SYSTEM_PROMPT) - val customBaseUrl: StateFlow = aiPreferencesRepository.customBaseUrl - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") - val currentAiBaseUrl: StateFlow = aiProvider .flatMapLatest { provider -> val p = AiProvider.fromString(provider) @@ -333,87 +253,10 @@ class SettingsViewModel @Inject constructor( } } - // Specific on-change methods for UI binding - fun onGeminiApiKeyChange(apiKey: String) { + fun onBaseUrlChange(baseUrl: String) { viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GEMINI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GEMINI") - else clearModelsState("GEMINI") - } - } - fun onDeepseekApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.DEEPSEEK, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "DEEPSEEK") - else clearModelsState("DEEPSEEK") - } - } - fun onGroqApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GROQ, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GROQ") - else clearModelsState("GROQ") - } - } - fun onMistralApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.MISTRAL, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "MISTRAL") - else clearModelsState("MISTRAL") - } - } - fun onNvidiaApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.NVIDIA, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "NVIDIA") - else clearModelsState("NVIDIA") - } - } - fun onKimiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.KIMI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "KIMI") - else clearModelsState("KIMI") - } - } - fun onGlmApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.GLM, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "GLM") - else clearModelsState("GLM") - } - } - fun onOpenAiApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENAI, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENAI") - else clearModelsState("OPENAI") - } - } - fun onOpenrouterApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OPENROUTER, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OPENROUTER") - else clearModelsState("OPENROUTER") - } - } - fun onOllamaApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.OLLAMA, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "OLLAMA") - else clearModelsState("OLLAMA") - } - } - fun onCustomApiKeyChange(apiKey: String) { - viewModelScope.launch { - aiPreferencesRepository.setApiKey(AiProvider.CUSTOM, apiKey) - if (apiKey.isNotBlank()) fetchAvailableModels(apiKey, "CUSTOM") - else clearModelsState("CUSTOM") - } - } - fun onCustomBaseUrlChange(baseUrl: String) { - viewModelScope.launch { - aiPreferencesRepository.setBaseUrl(AiProvider.CUSTOM, baseUrl) + val provider = AiProvider.fromString(aiProvider.value) + aiPreferencesRepository.setBaseUrl(provider, baseUrl) } } @@ -452,18 +295,6 @@ class SettingsViewModel @Inject constructor( } } - fun onGeminiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GEMINI, model) } - fun onDeepseekModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.DEEPSEEK, model) } - fun onGroqModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GROQ, model) } - fun onMistralModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.MISTRAL, model) } - fun onNvidiaModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.NVIDIA, model) } - fun onKimiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.KIMI, model) } - fun onGlmModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.GLM, model) } - fun onOpenAiModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENAI, model) } - fun onOpenrouterModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OPENROUTER, model) } - fun onOllamaModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.OLLAMA, model) } - fun onCustomModelChange(model: String) = viewModelScope.launch { aiPreferencesRepository.setModel(AiProvider.CUSTOM, model) } - fun onAiSystemPromptChange(prompt: String) { viewModelScope.launch { val provider = AiProvider.fromString(aiProvider.value) @@ -471,18 +302,6 @@ class SettingsViewModel @Inject constructor( } } - fun onGeminiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GEMINI, prompt) } - fun onDeepseekSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.DEEPSEEK, prompt) } - fun onGroqSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GROQ, prompt) } - fun onMistralSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.MISTRAL, prompt) } - fun onNvidiaSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.NVIDIA, prompt) } - fun onKimiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.KIMI, prompt) } - fun onGlmSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.GLM, prompt) } - fun onOpenAiSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENAI, prompt) } - fun onOpenrouterSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OPENROUTER, prompt) } - fun onOllamaSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.OLLAMA, prompt) } - fun onCustomSystemPromptChange(prompt: String) = viewModelScope.launch { aiPreferencesRepository.setSystemPrompt(AiProvider.CUSTOM, prompt) } - fun resetAiSystemPrompt() { viewModelScope.launch { val provider = AiProvider.fromString(aiProvider.value) @@ -490,18 +309,6 @@ class SettingsViewModel @Inject constructor( } } - fun resetGeminiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GEMINI) } - fun resetDeepseekSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.DEEPSEEK) } - fun resetGroqSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GROQ) } - fun resetMistralSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.MISTRAL) } - fun resetNvidiaSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.NVIDIA) } - fun resetKimiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.KIMI) } - fun resetGlmSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.GLM) } - fun resetOpenAiSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENAI) } - fun resetOpenrouterSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OPENROUTER) } - fun resetOllamaSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.OLLAMA) } - fun resetCustomSystemPrompt() = viewModelScope.launch { aiPreferencesRepository.resetSystemPrompt(AiProvider.CUSTOM) } - fun clearAiUsageData() { viewModelScope.launch { aiUsageDao.clearUsage() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt index 91e2d858fe..751fb52b06 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt @@ -104,7 +104,7 @@ class SongRemovalStateHolder @Inject constructor( suspend fun removeSongFromLibrary(song: Song) { libraryStateHolder.removeSong(song.id) - musicRepository.deleteById(song.id.toLong()) + song.id.toLongOrNull()?.let { musicRepository.deleteById(it) } playlistPreferencesRepository.removeSongFromAllPlaylists(song.id) } diff --git a/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/PlayerControlActionCallback.kt b/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/PlayerControlActionCallback.kt index fcd2a1e471..22fd1aced5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/PlayerControlActionCallback.kt +++ b/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/PlayerControlActionCallback.kt @@ -58,9 +58,11 @@ class PlayerControlActionCallback : ActionCallback { context.startService(serviceIntent) } Timber.tag(TAG).d("Service intent sent for action: $action") - } catch (e: android.app.ForegroundServiceStartNotAllowedException) { - Timber.tag(TAG).w(e, "Cannot start foreground service from background for action $action") } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is android.app.ForegroundServiceStartNotAllowedException) { + Timber.tag(TAG).w(e, "Cannot start foreground service from background for action $action") + return + } Timber.tag(TAG).e(e, "Error starting service for action $action: ${e.message}") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt index 3c44e7d595..d225c7e178 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt @@ -48,6 +48,7 @@ import com.theveloper.pixelplay.data.model.SyncedLine import com.theveloper.pixelplay.data.model.SyncedWord import kotlinx.coroutines.flow.Flow +import java.text.Normalizer import java.util.Locale import java.util.regex.Pattern import kotlin.math.PI @@ -119,8 +120,17 @@ object MultiLangRomanizer { fun isPunjabi(text: String) = text.any { it in '\u0A00'..'\u0A7F' } fun isCyrillic(text: String) = text.any { it in '\u0400'..'\u04FF' } fun isChinese(text: String) = text.any { it in '\u4E00'..'\u9FFF' } + fun isThai(text: String) = text.any { it in '\u0E00'..'\u0E7F' } + fun isArabic(text: String) = text.any { it in '\u0600'..'\u06FF' } + fun isGreek(text: String) = text.any { it in '\u0370'..'\u03FF' } + fun isHebrew(text: String) = text.any { it in '\u0590'..'\u05FF' } + fun isVietnamese(text: String): Boolean { + return text.any { it in '\u1EA0'..'\u1EF9' } || + text.any { it in "\u0102\u0103\u0110\u0111\u01A0\u01A1\u01AF\u01B0" } + } fun isScriptThatNeedsRomanization(text: String): Boolean { + if (isVietnamese(text) || isThai(text) || isArabic(text) || isGreek(text) || isHebrew(text)) return true return text.any { char -> val code = char.code code in 0x3040..0x309F || // Hiragana @@ -133,8 +143,8 @@ object MultiLangRomanizer { } } - private fun isRussian(text: String) = text.any { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isUkrainian(text: String) = text.any { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } + internal fun isRussian(text: String) = text.any { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } + internal fun isUkrainian(text: String) = text.any { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } private fun isSerbian(text: String) = text.any { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } private fun isBulgarian(text: String) = text.any { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } private fun isBelarusian(text: String) = text.any { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } @@ -547,6 +557,22 @@ object MultiLangRomanizer { } } + fun romanizeVietnamese(text: String): String { + if (text.isEmpty()) return text + val normalized = Normalizer.normalize(text, Normalizer.Form.NFKD) + val sb = StringBuilder(text.length) + for (char in normalized) { + when { + char == '\u0111' -> sb.append('d') + char == '\u0110' -> sb.append('D') + char in '\u0300'..'\u036F' || char in '\uFE20'..'\uFE2F' -> {} + char.code > 0x7E && char in '\u00C0'..'\u024F' -> {} + else -> sb.append(char) + } + } + return sb.toString() + } + private fun processCyrillicWordByWord(text: String, specificMap: Map, isRussian: Boolean = false): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()).filter { it.isNotEmpty() } diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt new file mode 100644 index 0000000000..5ac552e63c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt @@ -0,0 +1,46 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.stream.CloudStreamSecurity +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +object ServerUrlUtils { + + data class NormalizedUrl( + val url: HttpUrl, + val original: String, + val validationError: String? = null + ) + + fun normalizeUrlOrNull(raw: String): HttpUrl? { + val trimmed = raw.trim().trimEnd('/') + val withScheme = if (!trimmed.startsWith("http://", ignoreCase = true) && + !trimmed.startsWith("https://", ignoreCase = true) + ) { + "https://$trimmed" + } else { + trimmed + } + return withScheme.toHttpUrlOrNull() + } + + fun normalizeUrlString(raw: String): String = + normalizeUrlOrNull(raw)?.toString()?.trimEnd('/') ?: raw.trim().trimEnd('/') + + fun validateConnectionUrl(parsed: HttpUrl): String? { + if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) { + return "Server URL must not contain embedded credentials" + } + return null + } + + fun requireSecureForRemote(parsed: HttpUrl, service: String = "server"): String? { + if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) { + return "Server URL must not contain embedded credentials" + } + if (!parsed.isHttps && !CloudStreamSecurity.isLocalOrPrivateHost(parsed.host)) { + return "Use https:// for remote $service servers. HTTP is only allowed for local network addresses." + } + return null + } +} diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index afced2d2d2..b5dfcfddb9 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -13,7 +13,9 @@ السلوك الإيماءات، التفاعل اللمسي، وسلوك التنقل دمج الذكاء الاصطناعي (β) - مزودو الذكاء الاصطناعي، مفاتيح الـ API، وإعدادات النموذج + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration النسخ الاحتياطي والاستعادة تصدير واستعادة بيانات تطبيقك الشخصية خيارات المطورين diff --git a/app/src/main/res/values-de/plurals.xml b/app/src/main/res/values-de/plurals.xml new file mode 100644 index 0000000000..a364fd09a0 --- /dev/null +++ b/app/src/main/res/values-de/plurals.xml @@ -0,0 +1,59 @@ + + + + %d Playlist wird geteilt + %d Playlists werden geteilt + + + %1$d Playlist nach %2$s exportiert + %1$d Playlists nach %2$s exportiert + + + %d Song zur Warteschlange hinzugefügt + %d Songs zur Warteschlange hinzugefügt + + + %d Song wird als nächstes abgespielt + %d Songs werden als nächstes abgespielt + + + %d Song zu Favoriten hinzugefügt + %d Songs zu Favoriten hinzugefügt + + + %d Song aus Favoriten entfernt + %d Songs aus Favoriten entfernt + + + %d Datei gelöscht + %d Dateien gelöscht + + + %d Datei löschen? + %d Dateien löschen? + + + %d Titel ausgewählt + %d Titel ausgewählt + + + %1$d Song • %2$s + %1$d Songs • %2$s + + + %d Titel eingereiht. + %d Titel eingereiht. + + + %d Song ausgewählt + %d Songs ausgewählt + + + %d Song + %d Songs + + + %d Thema + %d Themen + + diff --git a/app/src/main/res/values-de/strings_changelogs.xml b/app/src/main/res/values-de/strings_changelogs.xml index b5e8947760..a0863d12d2 100644 --- a/app/src/main/res/values-de/strings_changelogs.xml +++ b/app/src/main/res/values-de/strings_changelogs.xml @@ -129,4 +129,22 @@ Lokalisierung: Spanisch, Französisch, Russisch, Vereinfachtes Chinesisch, Indonesisch, Italienisch + + Google Drive-Integration mit Player-Lebenszyklus-Management. + Batch-Bearbeitung von Song-Metadaten (Tags und Cover-Art). + KI-Übersetzung von Songtexten mit anpassbaren Wear OS-Einstellungen. + Latenzdiagnose-Tool und Mehrfachauswahl auf dem Suchbildschirm. + Arabische & Türkische Unterstützung, mit lokalisierten http-URL-Lokalisierungsoptionen. + + + Drastische Akku-Ersparnis (Audio-Offloading und UI-Polling-Gates). + Optimiertes Queue-Management (schnellere Einfügungen und explizite Indizierung). + Material 3 Expressive Motion-Animationen für Übergangsbildschirme. + Überarbeitete Bibliothekssynchronisierung mittels gedrosseltem Scannen. + + + Behobene Ruckel-/Aussetzer bei der Wiedergabe und Pufferungsprobleme. + Behobene Synchronisation externer Songlöschungen und Metadatenkonsistenz. + Behobene Speicherprobleme, Abstürze und Layout-Fehler auf Wear OS und Telefon. + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index e6fcda8436..3ae3244e7c 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -7,10 +7,14 @@ Design, Layout und Farben Wiedergabe Audio, Crossfade und Hintergrundwiedergabe + Songtexte + Quellpriorität, immersiver Modus und Importverwaltung Verhalten Gesten, Haptik und Navigation KI-Integration (β) - Anbieter, API Keys und Modelle + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup & Wiederherstellen App-Daten exportieren und importieren Entwickleroptionen @@ -174,6 +178,9 @@ Koreanisch Norwegisch Bokmål Türkisch + 日本語 + 繁體中文 + Tiếng Việt App-Design Hell, Dunkel oder System-Design – ganz nach Geschmack. Hell @@ -630,6 +637,15 @@ Die Person hinter PixelPlayer. Community-Spotlight Anerkennung für Mitwirkende mit besonders großem Einfluss. + Lizenzen und Hinweise + Zuschreibung für Abhängigkeiten und frühere Beiträge. + Open-Source-Lizenzen + Abhängigkeitslizenzen und Drittanbieter-Hinweise + Open-Source-Lizenzen + MIT-ähnlicher Hinweis + Drittanbieter-Hinweise + Schließen + Der gebündelte Drittanbieter-Hinweis konnte nicht geladen werden. Open-Source-Mitwirkende Aktuelle Mitwirkenden-Liste von GitHub. %1$d Beiträge diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index 79a7e62578..ef9f488064 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -10,7 +10,9 @@ Comportamiento Gestos, vibración y navegación Integración IA (β) - Proveedores de IA, claves API y modelos + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Copia y restauración Exporta y recupera tus datos personales Opciones de desarrollador @@ -174,6 +176,9 @@ Coreano Noruego (Bokmål) Turco + Japonés + Chino tradicional + Vietnamita Tema de la app Cambia entre claro, oscuro o seguir el sistema. Tema claro diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 5932d39357..426ae3a21f 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -10,7 +10,9 @@ Comportement Gestes, retour haptique et comportement de navigation Intégration IA (β) - Fournisseurs d\'IA, clés API et paramètres du modèle + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Sauvegarde et restauration Exporter et récupérer vos données personnelles de l\'application Options développeur @@ -170,6 +172,9 @@ Coréen Norvégien (Bokmål) Turc + Japonais + Chinois traditionnel + Vietnamien Thème de l\'application Passer du mode clair au mode sombre, ou suivre l\'apparence du système. Thème clair diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index cabbc3ab0d..d2eff886fe 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -10,7 +10,9 @@ Perilaku Gerakan, haptik, dan perilaku navigasi Integrasi AI (β) - Penyedia AI, kunci API, dan pengaturan model + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Cadangkan & Pulihkan Ekspor dan pulihkan data aplikasi pribadi Anda Opsi Pengembang @@ -170,6 +172,9 @@ Korea Norwegia (Bokmål) Turki + Jepang + Tionghoa Tradisional + Vietnam Tema Aplikasi Beralih antara terang, gelap, atau ikuti tampilan sistem. Tema Terang diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index 24ad47887e..cff5cadc1e 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -10,7 +10,9 @@ Comportamento Gesti, feedback aptico e comportamento navigazione Integrazione AI (β) - Provider AI, chiavi API e impostazioni modello + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup e ripristino Esporta e recupera i tuoi dati personali dell\'app Opzioni sviluppatore @@ -174,6 +176,9 @@ Coreano Norvegese (Bokmål) Turco + Giapponese + Cinese tradizionale + Vietnamita Tema app Passa tra chiaro, scuro o segui l\'aspetto di sistema. Tema chiaro diff --git a/app/src/main/res/values-ja/plurals.xml b/app/src/main/res/values-ja/plurals.xml new file mode 100644 index 0000000000..c18d5b2cba --- /dev/null +++ b/app/src/main/res/values-ja/plurals.xml @@ -0,0 +1,45 @@ + + + + %d 件のプレイリストを共有中 + + + %1$d 件のプレイリストを %2$s にエクスポートしました + + + %d 曲をキューに追加しました + + + %d 曲が次に再生されます + + + %d 曲をお気に入りに追加しました + + + %d 曲をお気に入りから削除しました + + + %d ファイルを削除しました + + + %d ファイルを削除しますか? + + + %d 曲を選択中 + + + %1$d 曲 • %2$s + + + %d 曲が予約されました。 + + + %d 曲を選択中 + + + %d 曲 + + + %d 件のトピック + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..5dedb3c73a --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,128 @@ + + + PixelPlayer + 音楽プレイヤー + アプリ名の変更について + 商標上の理由により、アプリ名を PixelPlay から PixelPlayer に変更しました。引き続きお楽しみください! + 今後表示しない + + + ホーム + 検索 + ライブラリ + + + 特別な権限が必要です + 曲のメタデータ(.mp3 ファイル)を編集するには、PixelPlayer にすべてのファイルへの特別なアクセス権限が必要です。これにより、トラックのタグを直接変更できます。メタデータ編集を有効にするには、次の画面でこの権限を許可してください。 + 権限を許可 + + + すぐに再生 + このオーディオファイルを開けませんでした。 + フルプレイヤーを開く + + + シャッフル + すべての曲をシャッフル + すべてシャッフル + 最後のプレイリスト + 開けるプレイリストがありません + + + Play ストアを開く + ベータを続ける + Play ストアのリンクは GitHub の設定から有効化されます。 + PixelPlayer が Google Play で公開されました + リリース更新は Google Play の安定版チャンネルをご利用ください。ベータビルドも引き続き提供されます。 + PixelPlayer + リリースのお知らせ + 近日公開 + + + PixelPlayer をご利用いただきありがとうございます! + ハイスコア %1$d + 閉じる + スコア + レベル %1$d + ライフ + レベルクリア! + ゲームオーバー + スコア: %1$d + もう一度? + 次のレベル + ゲームを再起動 + タップして再起動 + ランダムに音楽を再生 + ブロック崩し + ハイスコア %1$d + プレイ + ドラッグしてパドルを動かす + + + プレイヤーを閉じる + 再生操作を処理中… + 再生エラー: %1$s + + + 戻る + OK + キャンセル + 閉じる + エラー + 検索 + 検索をクリア + すべて + 確認 + 保存しました! + 選択済み + %1$d%% + アーティスト + すべて選択 + クリア + 不明なエラー + + + 保存 + 完了 + リセット + 適用 + シャッフル + コピー + 共有 + 元に戻す + インポート + 削除 + エクスポート + 結合 + 名前を変更 + 作成 + 歌詞 + 設定 + アルバムアート + プレイリスト + 不明なトラック + 不明なアーティスト + 不明なアルバム + 閉じる + 追加 + 削除 + 再生 + 前のトラック + 次のトラック + お気に入り + 一時停止 + リピート + オプション + シャッフル再生 + %1$s のその他のオプション + メニューを展開 + 次へ + 完了 + デフォルトに戻す + すべてエクスポート + すべて結合 + すべて共有 + アルバムを再生 + アルバムをシャッフル再生 + %1$s のアルバムアート + diff --git a/app/src/main/res/values-ja/strings_changelogs.xml b/app/src/main/res/values-ja/strings_changelogs.xml new file mode 100644 index 0000000000..85544d5ba6 --- /dev/null +++ b/app/src/main/res/values-ja/strings_changelogs.xml @@ -0,0 +1,9 @@ + + + 変更履歴 + GitHub で見る + 改善 + 修正 + 新機能 + 追加 + diff --git a/app/src/main/res/values-ja/strings_cloud_services.xml b/app/src/main/res/values-ja/strings_cloud_services.xml new file mode 100644 index 0000000000..a2e5bac754 --- /dev/null +++ b/app/src/main/res/values-ja/strings_cloud_services.xml @@ -0,0 +1,226 @@ + + + + Telegram ログイン + 番号を編集中です。再送すると前のコードが無効になります。 + 処理中… + Telegram を初期化中… + ログアウト中… + セッションを閉じています… + セッションが閉じました。続けるにはログインを再度開いてください。 + 安全な Telegram セッションを準備中… + Telegram からの応答を待機中… + Telegram に接続 + Telegram に接続してチャンネルやチャットから音楽をストリーミングします。 + 電話番号 + Telegram の番号を入力してください。後で戻って編集することもできます。 + 電話番号 + 81 + 09012345678 + コードを送信 + 確認コード + Telegram からのコードを入力してください。番号が間違っている場合は戻って修正してください。 + コード + 12345 + 電話番号を編集 + コードを再送 + コードを確認 + 二段階認証パスワード + Telegram のパスワードを入力してください。番号を修正するために戻ることもできます。 + パスワード + パスワードを確認 + しばらくお待ちください… + + + Telegram チャンネル + チャンネルを追加 + Telegram パブリックチャンネル + 同期中 + 今すぐ同期 + トピックを折りたたむ + トピックを表示 + チャンネルオプション + トピック + チャンネルを同期中 + Telegram から曲を更新中 + このチャンネルから最新の曲を取得 + チャンネルを削除 + 同期を停止してキャッシュされた曲を削除 + チャンネルを削除しますか? + %1$s の同期が停止し、このチャンネルのキャッシュされた曲がすべて削除されます。 + 削除 + 同期済みチャンネルがありません + Telegram のパブリックチャンネルを追加して\n音楽ライブラリを同期しましょう + チャンネルを追加 + 未同期 + %1$s に同期 + + + チャンネルを追加 + 音楽を同期する Telegram パブリックチャンネルを検索 + \@チャンネル名またはリンク + 検索中… + チャンネルを検索 + パブリックチャンネルのユーザー名またはリンクを入力して\nオーディオファイルを同期してください + + + %d 曲 + + + %d トピック + + + + Subsonic + Navidrome、Airsonic などの Subsonic 互換サーバーを管理します。 + + + 同期をタップして Jellyfin のプレイリストを取得してください + Jellyfin サーバーの接続を管理します。 + + + 音楽フォルダ + + をタップして Drive フォルダを追加 + フォルダがまだ追加されていません + %1$d フォルダが同期済み + フォルダを追加 + + + プレイリストの種類を選択 + 同期するプレイリストを選択: + すべてのプレイリスト + 作成 & お気に入り + 作成したプレイリスト + お気に入りのプレイリスト + + + %1$d プレイリストが同期済み + プレイリスト + 同期 + まだプレイリストが同期されていません + 同期をタップしてプレイリストを取得してください + クイックアクション + ライブラリを同期 + 切断 + %1$d 曲 + + + 同期中 + ライブラリを同期中… + プレイリストを取得中… + プレイリストを同期中: %1$s + ローカルライブラリを更新中… + 同期完了 + アルバムリストを取得中… + %1$s から曲を取得中… + %1$d 曲をデータベースに保存中… + ライブラリに曲が見つかりません + ライブラリ同期完了 + 同期中… + エラー: %1$s + + + 同期 + すべて同期 + ログアウト + すべてのプレイリストを同期 + ユーザーアバター + + + インターネット接続がありません + このコンテンツにはインターネット接続が必要です。ネットワーク設定を確認して再試行してください。 + オフラインです + このコンテンツにアクセスするにはインターネット接続を確認して再試行してください。 + + + 接続 + 接続中… + サーバー URL とアカウントの認証情報を入力してください。 + 接続詳細 + パスワードを非表示 + パスワード + パスワードを入力 + http:// を入力 + サーバー URL + パスワードを表示 + Telegram + ユーザー名 + admin + ようこそ、%1$s! + + + Navidrome、Gonic、Airsonic などの Subsonic 互換サーバーに対応 + Navidrome、Airsonic、Gonic、Ampache などの Subsonic API 互換サーバーをサポートします。 + サーバーが対応している場合はアプリパスワードも使用できます。 + https:// を入力 + セルフホスト型音楽サーバーに接続 + Navidrome + サーバーの完全な https:// ベースアドレスを使用してください。 + https://music.example.com + Subsonic または Navidrome のアカウント名です。 + Subsonic / Navidrome + Subsonic + + + Jellyfin サーバー URL とアカウントの認証情報を入力してください。 + 音楽ライブラリをストリーミングするために Jellyfin サーバーに接続します + Jellyfin サーバーに接続します。ローカルネットワークアクセスには HTTP と HTTPS の両方がサポートされています。 + Jellyfin + Jellyfin アカウントのパスワード。 + Jellyfin メディアサーバーに接続 + Jellyfin + ポートを含む Jellyfin サーバーの完全な URL。 + http://192.168.1.100:8096 + Jellyfin アカウントのユーザー名。 + + + Google Drive から直接音楽ファイルをストリーミング + Google Drive に接続 + Google Drive に接続しました! + 「PixelPlayer Music」を作成 + ここに音楽用の新しいフォルダを作成 + フォルダがありません + フォルダを開く + 音楽ソースとして使用するフォルダを選択または作成 + 音楽フォルダを選択 + Google Drive をセットアップ中… + Google でサインイン + Google Drive + 使用 + + + セッション Cookie を読み取れませんでした。 + 完了 + 終了 + Cookie が見つかりません。先にログインしてください。 + ページの読み込みに時間がかかっています。更新するか別のネットワークをお試しください。 + + + 保存中… + 残る + ページの読み込みがタイムアウトしました。進捗を失わずに再試行できます。 + Web で戻る + 後で戻れます。閉じると現在のページの状態は破棄されます。 + Web で進む + 更新 + 再試行 + ホームを開く + WebView の読み込みに失敗しました。 + + + NetEase の Cookie を読み取れませんでした: %1$s + NetEase のログインを終了しますか? + NetEase の読み込み中に HTTP %1$d エラーが発生しました。 + まだログインが検出されていません。完了を押す前に NetEase のログインを完了してください。 + NetEase Music にログイン + セキュリティについて: パスワードは NetEase のウェブページにのみ入力されます。PixelPlayer はライブラリを同期するためにセッション Cookie(MUSIC_U)を保存します。 + NetEase Music + + + QQ Music の Cookie を読み取れませんでした: %1$s + QQ Music のログインを終了しますか? + QQ Music の読み込み中に HTTP %1$d エラーが発生しました。 + まだログインが検出されていません。完了を押す前に QQ Music のログインを完了してください。 + QQ Music にログイン + セキュリティについて: パスワードは QQ Music のウェブページにのみ入力されます。PixelPlayer はライブラリを同期するためにセッション Cookie を保存します。 + QQ Music + diff --git a/app/src/main/res/values-ja/strings_equalizer.xml b/app/src/main/res/values-ja/strings_equalizer.xml new file mode 100644 index 0000000000..3c36811ada --- /dev/null +++ b/app/src/main/res/values-ja/strings_equalizer.xml @@ -0,0 +1,57 @@ + + + + 名前を入力してください + 名前を変更 + + + 表示モードを変更 + イコライザーを無効化 + イコライザーを有効化 + 編集 + プリセットを編集 + カスタムプリセット + プリセット + 更新 + バスブースト + バーチャライザー + ラウドネス + 非対応 + この端末では非対応 + 音量 + 周波数特性 + Hz + バス + ローミッド + ハイミッド + トレブル + バス / ロー + ミッド / ハイ + ページ %1$d + 時間をリセット + 新規保存 + + + 保存済みプリセット + カスタムプリセットがまだ保存されていません。 + ピンを外す + ピン留め + 名前を変更 + 削除 + + + カスタムプリセットを保存 + カスタムイコライザープリセットの名前を入力してください。 + プリセット名 + プリセット名を変更 + + + プリセットを管理 + ドラッグして並び替え • 目のアイコンで表示/非表示を切り替え + 並び替え + プリセットをリセット + デフォルトのプリセット順と表示状態に戻します。続けますか? + デフォルトに戻す + 表示 + 非表示 + diff --git a/app/src/main/res/values-ja/strings_home_screen.xml b/app/src/main/res/values-ja/strings_home_screen.xml new file mode 100644 index 0000000000..ac2021f3e9 --- /dev/null +++ b/app/src/main/res/values-ja/strings_home_screen.xml @@ -0,0 +1,276 @@ + + + + β + ベータ + クラウドストリーミング + 変更履歴 + クラウドストリーミング + クラウドアカウントから音楽をストリーミング + + + Beta 0.7.0 + β + PixelPlayer 0.7.0-beta へようこそ + バグ、クラッシュ、または試験的な機能が含まれている可能性があるベータビルドを使用しています。問題を報告して改善にご協力ください。 + 期待されること + バグ、クラッシュ、または未完成の機能が予期せず発生することがあります。 + 一部の機能は予告なく変更または削除される場合があります。 + ベータビルドはリリース版より不安定な場合があります。 + 既知の問題を報告する前に必ず最新版を確認してください。 + テスト中にベータビルドが変更、破損、または改善される可能性があること。 + GitHub Issue のショートカット + まず検索してから、バグ、クラッシュ、要望、質問に対する集中したレポートを作成してください。 + 既存の Issue を開く + Issue またはクラッシュを報告 + 再現手順、期待される結果、実際の結果、デバイス/OS の詳細を共有してください。 + 報告方法 + 新しい Issue を開く前の簡単なチェックリスト。 + Issue を開く前に + 重複を避けるために既存のオープンおよびクローズ済みの Issue を検索してください。 + 最新の PixelPlayer バージョンに更新して問題が引き続き発生することを確認してください。 + アプリを再起動して問題が続くことを確認してください。 + 再現を試みて正確な手順を書き留めてください。 + Issue の種類は? + バグ報告: 何かが正しく動作しない。 + 機能リクエスト: 新機能や改善の追加。 + 質問: Discussions が有効な場合はそちらを使用するか、question ラベルで Issue を開いてください。 + バグ報告 + 何かが正しく動作しないまたはクラッシュする場合にこれらのフィールドをコピーしてください。 + バグ報告 + 概要: + 期待される動作: + 現在の動作: + 再現手順: 1. 2. 3. + 頻度は? 常時 / 時々 / まれに。 + スクリーンショット / 動画: あれば。 + ログ / スタックトレース: あれば。 + 環境 + PixelPlayer バージョン: + インストール元: GitHub リリース、デバッグビルド、ナイトリービルドなど。 + Android バージョン: + 端末モデル: + 補足情報: SD カードの使用、特別な設定、権限など。 + 機能リクエスト + 新機能や改善を要望する場合にこれらのフィールドをコピーしてください。 + 問題の説明: 解決しようとしている問題は何ですか? + 提案する解決策: どのように機能すればよいですか? + 検討した代替案: 他のアプローチはありますか? + 範囲: どの画面やフローが影響を受けますか? + 利用可能であればモックアップや参考画像。 + タイトル、プライバシー、範囲 + 報告をトリアージしやすく安全に共有できるようにします。 + 良い Issue タイトルの例 + イコライザー: プリセットタブを切り替えるとインジケーターがずれる + 検索: 空のクエリで履歴リストが表示されない + 機能: 「最近追加された」プレイリストの並び替えオプションを追加 + 避けるべきこと + 「動かない」のような一般的な報告。 + 1 つの Issue に複数の無関係な問題を含める。 + プライベートデータが含まれた未編集のログやスクリーンショット。 + プライバシーについて + ログ、スクリーンショット、動画を投稿する前に個人情報やプライベートな情報を削除してください。 + ナイトリービルド + ナイトリーとリリースの違い、および破損した場合に含めるべき情報。 + ナイトリービルドは最新のコミットから生成され、未完成の変更、一時的なバグ、またはリグレッションが含まれる場合があります。公式リリースよりも試験的です。 + 利用可能な場合はリポジトリの GitHub Actions ワークフローアーティファクトからアクセスできます。 + ナイトリーの問題を報告する + ナイトリービルドで問題を報告する場合は、公式リリースではなくナイトリービルドで発生したことを必ず記載してください。可能であればビルド日、ワークフロー実行名または番号、コミット SHA を含めてください。また同じ問題が最新の公式リリースでも発生するか確認してください。 + Beta 0.5.0 アップグレード + クリーンインストール推奨 + beta 0.5.0 からのアップデートの場合、このアップデートでは古いキャッシュ状態ではなく新しいライブラリデータが必要な場合があります。 + メタデータやライブラリエントリがおかしい場合 + 曲のメタデータが間違っている、アーティストやアルバムが一致しない、または重複しているように見えるエントリは通常クリーンインストールで解決します。 + 今後表示しない + 了解 + + + 問題が発生しました + 前回のセッション中にアプリがクラッシュしました。クラッシュレポートを共有して修正にご協力ください。 + 日時: %1$s + エラー: + スタックトレース(プレビュー): + クラッシュログ + クラッシュログをクリップボードにコピーしました + PixelPlayer クラッシュレポート + クラッシュレポートを共有 + + + DJ ミキサー + + + あなたの\nミックス + まだ表示するデータがありません + PixelPlayer が曲を見つけるかソースを同期するとミックスがここに表示されます。 + 更新 + + + デイリーミックス + 履歴に基づく + デイリーミックスをすべて確認 + デイリーミックス + + + デイリーミックス + + %1$d 曲 • %2$s + + 再生する + AI プレイリストジェネレーター + + + デイリーミックスの作られ方 + デイリーミックスはお気に入りのよく再生される曲から作られます。好みのアーティストやジャンルのトラックも追加されるので新しい音楽を発見できます。 + 今日何を聴きたいか AI に伝えましょう + コストを抑えるため少量のサンプルを使用します + 更新中… + デイリーミックスを更新 + + + 完璧にキュレーション + デイリーミックス + あなたのソニックジャーニーの準備ができました + AI プレイリストジェネレーター + 雰囲気、ムード、アクティビティを説明して、ライブラリから AI に完璧なプレイリストをキュレーションさせましょう。 + プレイリストのサイズ + 最小曲数 + 最大曲数 + 例: チルな夜の雰囲気、アップビートなワークアウトエネルギー… + タップして再試行 + ソニックジャーニーが完成しました! + 再生準備完了 + 生成中… + プレイリストを生成 + + + 最近再生した曲 + + + 最近再生した曲 + 最新を再生 + %1$s に最近の再生はありません + 範囲を変更するか、タイムラインを埋めるためにもっと曲を再生してください。 + 最近再生した曲 + 今日 + 昨日 + + + リスニング統計 + 総再生回数 + 1 日平均 + トップトラック + %1$s • %2$d 回 + + + リスニング統計 + リスニング統計を更新 + 今日 + 今週 + 今月 + 今年 + 全期間 + リスニング + 再生 + リスニングタイムライン + リスニング時間 + 選択した範囲でのリスニングの合計。 + 再生回数 + セグメントごとに完了したセッション数。 + 平均セッション + 各セグメントの平均リスニング時間。 + 4 時間ごとに分割して日々のリズムを確認できます。 + 日別バーで週ごとの習慣を比較しやすくします。 + 週別バーで月のトレンドを確認できます。 + 月別バーで年間の季節性を確認できます。 + 年別バーで全履歴を要約します。 + まだリスニングデータがありません + 再生を始めてリスニングタイムラインを構築しましょう + 日々のリズム + 週のリズム + 月のリズム + 年間一覧 + 全期間の推移 + 4 時間ごとのセグメントでグループ化 + 曜日でグループ化 + 月の週でグループ化 + 月でグループ化 + 年でグループ化 + ピークセグメント + %1$d 回 + + トップカテゴリ + ジャンル、アーティスト、アルバム、曲ごとのリスニングを比較します。 + ジャンル + アーティスト + アルバム + + ジャンル別リスニング + アーティスト別リスニング + アルバム別リスニング + 曲別リスニング + %1$d 回 • %2$d アーティスト + %1$d 回 • %2$d トラック + まだカテゴリデータがありません + 再生を始めてリスニングのハイライトを確認しましょう + リスニング習慣 + まだ習慣データがありません + あなたのことをより知ったらリスニング習慣を表示します。 + 総セッション数 + 平均セッション + 最長セッション + セッション/日 + 最もアクティブな日 + まだ再生履歴がありません + ピークタイムラインスロット + トップアーティスト + トップアーティストがいません + 聴き続けるとお気に入りのアーティストがここに表示されます。 + \? + %1$d. %2$s + トップアルバム + トップアルバムがありません + よく聴くアルバムがここに表示されます。 + %1$d. %2$s + トラック集中度 + トップトラック全体でリスニング時間がどのように分散しているか。 + まだ集中度データがありません + より多くのトラックを再生してリスニングの集中度を確認しましょう。 + トップ 1 + トップ 2-3 + その他 + %1$d%% + リスニング集中度 + トップ 3 トラックがリスニング時間の %1$d%% を占めています。 + 平均再生回数/トラック + ユニークトラック + トップ 3 シェア + この期間のトラック + 選択した期間で最も再生されたトラック。 + トップトラックがありません + お気に入りを聴き続けるとここでハイライトされます。 + トラックを折りたたむ + すべてのトラックを表示 + + + %1$d 時間 %2$02d 分 + %1$d 分 + %1$d 時間 %2$02d 分 + %1$d 時間 + %1$d 分 + %1$d 秒 + %1$d 時間 %2$02d 分 + %1$d 時間 + %1$d 分 + %1$d 秒 + なし + たった今 + 1 日前 + %1$d 日前 + 1 時間前 + %1$d 時間前 + 1 分前 + %1$d 分前 + %1$d 曲 + %1$d 曲 + 第 %1$d 週 + diff --git a/app/src/main/res/values-ja/strings_library.xml b/app/src/main/res/values-ja/strings_library.xml new file mode 100644 index 0000000000..4c7c2d6ca2 --- /dev/null +++ b/app/src/main/res/values-ja/strings_library.xml @@ -0,0 +1,558 @@ + + + + ライブラリ + ライブラリタブ + 任意のタブへ直接ジャンプするか、順序を変更できます。 + タブを並び替え + + + + アルバム + アーティスト + プレイリスト + フォルダ + お気に入り + + + プレイリストを作成しました + 先に AI プロバイダーの API キーを設定してください + 先に Gemini API キーを設定してください + キューに追加しました + 次に再生 + + + Watch への転送 + 設定 + 編集 + タブを並び替え + メニューを展開 + + + 選択できるアルバムは最大 %1$d 枚です + フォルダ + フォルダ + + + 並び替え + 表示 + プレイリスト表示 + グリッド + リスト + 内部ストレージ + SD カード + SD カードは現在利用できません。 + クラウド + Telegram クラウドチャンネル + トピック表示 + チャンネル + トピック + 両方 + クラウド + クラウドのみ + + + AI でメタデータを生成中… + + + 曲の読み込みエラー + アルバムの読み込みエラー + アーティストの読み込みエラー + 再試行 + + + ライブラリに曲が見つかりませんでした。 + 端末に音楽がある場合は、設定からライブラリを再スキャンしてみてください。 + 曲が見つかりません + + + 新規 + 新しいプレイリストを作成 + M3U プレイリストをインポート + 現在の曲を探す + すべての曲 + クラウド + ローカル + 並び替えオプション + + + すべて + 選択解除 + その他のオプション + + + 音楽ファイルをスキャン中… + ファイルを処理中… + %2$d 件中 %1$d 件 + ライブラリを同期中… + 同期完了 + 待機中… + ライブラリを同期中… + アルバムアートキャッシュをクリア中… + クラウドソースを同期中… + 歌詞をスキャン中… + + + 曲がまだありません + 音楽を端末に追加するか、クラウドソースを同期して再生を始めましょう。 + ローカルの曲が見つかりません + 別のソースフィルターを試すか、端末のライブラリを再スキャンしてください。 + クラウドの曲が見つかりません + Telegram や NetEase の曲を同期するか、ローカルソースに切り替えてください。 + アルバムがありません + ライブラリにトラックがグループ化されるとアルバムが表示されます。 + ローカルアルバムが見つかりません + ローカルアルバムを作成するにはローカルの曲が必要です。 + クラウドアルバムが見つかりません + アルバムデータを持つクラウドの曲は同期後にここに表示されます。 + アーティストがいません + いずれかのソースから曲がインデックスされるとアーティストが表示されます。 + ローカルアーティストが見つかりません + ローカルの曲にアーティストのメタデータがありません。 + クラウドアーティストが見つかりません + リモートの曲が同期されるとクラウドアーティストが表示されます。 + お気に入りの曲がまだありません + 再生中にハートアイコンをタップして曲を保存しましょう。 + お気に入りのローカル曲がありません + ソースフィルターを切り替えるか、端末の曲をお気に入りに追加してください。 + お気に入りのクラウド曲がありません + Telegram や NetEase のトラックをお気に入りに追加するとここに表示されます。 + フォルダが見つかりません + 音楽が入った内部ストレージのフォルダがここに表示されます。 + プレイリストがまだありません + 最初のプレイリストを作成してライブラリを整理しましょう。 + + + 曲のメタデータを編集 + 再生 + 曲を再生 + すべて再生 + すべて再生 + お気に入りに追加 + すべてお気に入りに追加 + お気に入りから削除 + すべてお気に入りから削除 + 曲ファイルを共有するアプリを選択 + 曲ファイルを共有 + すべてを ZIP で共有 + 曲を共有できませんでした: %1$s + キューに追加 + キューに追加 + 次に再生 + キューで次に再生 + プレイリストに追加 + 削除 + すべて削除 + Watch を確認中 + 転送中 %1$d%% + Watch に転送中 + 転送中 + Watch に送る + Watch が利用できません + 曲を Watch に送る + Watch が利用できません + サウンドとして設定 + サウンドとして設定 + この曲をシステムサウンドとして使う方法を選択 + この曲を使う場所 + PixelPlayer がこのサウンドをインストールする場所を選択してください。 + 着信音 + 電話の着信 + 通知音 + メッセージとアプリの通知 + アラーム音 + 時計のアラーム + サウンドの変更を確認 + 「%1$s」を %2$s に設定しますか? + サウンドを設定 + 「%1$s」を %2$s に設定しました + 着信音 + 通知音 + アラーム音 + 「システム設定の変更」を有効にしてから PixelPlayer に戻ると自動で完了します。 + 「システム設定の変更」が有効になっていません。 + 「%1$s」を着信音に設定しました + 着信音にはローカルの曲のみ使用できます。 + この音声ファイルを着信音用に準備できませんでした。 + 着信音を設定できませんでした: %1$s + オプション + オプション + 情報 + 情報 + 再生時間 + ジャンル + アルバム + アーティスト + 曲の情報 + プロバイダー + ファイル + %1$d 曲 + 選択中 + %1$d プレイリスト + %1$d アルバム + 選択中 + 上限: %1$d アルバム + キューへの追加と再生は選択順序に従います。 + %1$d ジャンル + 選択中 + 選択したジャンル内のすべての曲に対して一括操作を実行します。 + + + デフォルト順 + タイトル(A〜Z) + タイトル(Z〜A) + アーティスト + アーティスト(Z〜A) + アルバム + アルバム(Z〜A) + 追加日 + 追加日(古い順) + 再生時間 + 再生時間(短い順) + リリース年 + リリース年(古い順) + 曲数が少ない順 + 曲数が多い順 + 名前(A〜Z) + 名前(Z〜A) + 曲数(多い順) + 曲数(少ない順) + 作成日 + 作成日(古い順) + お気に入り追加日 + お気に入り追加日(古い順) + サブフォルダが少ない順 + サブフォルダが多い順 + + + タイトル + アーティスト + アルバム + 追加日 + 再生時間 + リリース年 + 曲数 + 名前 + 曲数 + 作成日 + お気に入り追加日 + サブフォルダ数 + + + ソース + 順序 + 降順 + 昇順 + 元の順序 + タップして昇順に切り替え + タップして降順に切り替え + この並び替えは元の順序を維持します + スイッチがオン + + + ライブラリタブを並び替え + 順序をリセット + タブの順序をデフォルトに戻しますか? + タブを並び替え中… + ドラッグハンドル + + + アーティストを選択 + 1 アーティスト + %1$d アーティスト + メインアーティスト + アーティストページ + + + 転送をキャンセル + %1$s / %2$s + スマートフォンから Watch への音楽転送の進捗をリアルタイムで表示します + Watch への転送 + Watch に送信中 + キャンセル済み + 転送をキャンセルしました + 転送が完了しました + 完了 + 失敗 + 転送に失敗しました + 複数の転送が進行中 + %1$s • %2$s + 準備中 + Watch への転送を準備中 + 転送を準備中… + Watch に %1$d 曲を送信中 + Watch に送信中 + 転送を開始中… + 開始中 + 転送中 + %1$d 件の転送 + + + 曲を編集 + 情報を表示 + 曲のメタデータを編集中 + 曲のメタデータを編集すると、ライブラリでの表示や整理に影響することがあります。変更は永続的で、元に戻せない場合があります。 + 了解 + 情報 + カバーアート + 正方形の画像を選択して調整し、アプリ全体でカバーアートが美しく表示されるようにしましょう。 + カバーアートを変更 + カバーアートを削除 + タイトル + アーティスト + アルバム + アルバムアーティスト + ジャンル + 作曲者 + トラック番号 + ディスク番号 + ReplayGain トラック(dB) + ReplayGain アルバム(dB) + -6.50 + -8.20 + 新しいカバーアートのプレビュー + 現在の曲のカバーアート + カバーアートを調整 + ピンチとドラッグで最適なフレーミングを見つけてください。 + カバーアートを適用 + 選択した画像を読み込めませんでした + lrclib.net で歌詞を検索 + + + %d 曲を編集 + 変更したフィールドのみ更新されます。空白のフィールドは既存の値が保持されます。 + (複数の値) + (任意 — スキップする場合は空白のまま) + %d 曲を更新しました + %2$d 曲中 %1$d 曲を更新しました。一部のファイルは編集できませんでした。 + 曲の更新に失敗しました + カバーアートの一括変更 + 選択した %d 曲すべてのカバーアートが置き換えられます + すべてにカバーアートを設定 + すべてのカバーアートを削除 + (複数の異なるカバー) + + + プレイリストを閉じました + + + プレイリストを作成 + 作成方法を選択してください。 + 手動 + アートワーク・アイコン・形状をデザインし、曲を自分で選びます。 + AI で作成 + 高度なコントロールでキュレーションされたプレイリストを生成します。 + 設定で Gemini API キーを設定する必要があります。 + API キーを設定 + + + AI プレイリストラボ + リセット + 生成中… + 生成 + 意図 + プレイリスト名(任意) + このプレイリストの雰囲気は? + 例:夕暮れのドライブにウォームなシンセ + 方向性 + ムード + アクティビティ + 年代 + キュレーション + エネルギー + 曲の強度とテンポを調整します。1 = 穏やか/スロー、5 = ハイエネルギー/ファスト。 + ディスカバリー + 選曲の馴染み度を調整します。1 = 最もよく聴くお気に入り、5 = あまり聴いていないレアな曲。 + 最小曲数 + 最大曲数 + フィルター + 優先するジャンル(任意) + 例:シンセウェーブ、インディーポップ + 避けるジャンル(任意) + 例:メタル、ハードトラップ + 優先言語(任意) + 例:日本語、英語、インストゥルメンタル + お気に入りを優先 + 不適切な歌詞を除外 + プロンプトのプレビュー + 好みを追加すると最終プロンプトがここに表示されます。 + 精密なキュレーション + ムード・アクティビティ・制約・深さを定義します。 + AI はローカルライブラリの曲のみを使用します。 + AI への指示を少なくとも 1 つ追加してください。 + 有効な曲数の範囲を設定してください。 + %1$d/5 + カスタム… + カスタム値を入力 + カスタム値を入力してください + + + すべての年代 + コアリクエスト: %1$s。 + ムード目標: %1$s。 + アクティビティ: %1$s。 + 年代: %1$s。 + 優先ジャンル: %1$s。 + 避けるジャンル: %1$s。 + 優先言語: %1$s。 + エネルギーレベル目標: %1$d/5。 + ディスカバリー目標: %1$d/5(1 = 馴染みあり、5 = レアな掘り出し物)。 + 可能な限りお気に入りに近い曲を優先する。 + 代替曲がある場合は不適切な歌詞を避ける。 + スムーズなトランジションを維持し、同じアーティストが連続しないようにする。 + + チル + エネルギッシュ + ハッピー + ダーク + ロマンティック + メランコリック + + + ワークアウト + 集中 + ロードトリップ + パーティー + 勉強 + 深夜 + + + @string/playlist_creation_ai_era_any + 70年代 + 80年代 + 90年代 + 2000年代 + 2010年代 + 2020年代 + + + + プレイリストがまだ作成されていません。 + 「新しいプレイリスト」ボタンをタップして始めましょう。 + 新しいプレイリスト + プレイリスト名 + マイプレイリスト + + + %1$d 曲を追加先… + プレイリストを選択 + プレイリストを検索… + プレイリストに曲を追加しました + プレイリストを作成して曲を追加しました + 内部ストレージ + + + 曲を追加 + 選択した曲を追加 + 追加 + 曲を検索またはフィルター… + お気に入り + 曲の読み込みに失敗しました + さらに読み込む + + + プレイリストを結合 + 結合後のプレイリスト名を入力してください: + 結合プレイリスト + 選択した %1$d 件のプレイリストを 1 つに結合します。 + + + 再生できる有効な曲が見つかりませんでした + 現在のリストに曲が見つかりません + 曲を見つけられませんでした + ライブラリに曲が見つかりません + %1$s の再生が終了しました(トラック終了)。 + トラック + シャッフルする曲がありません。 + 選択したアルバム + 選択したアルバムに再生可能な曲が見つかりませんでした + 選択したジャンルに再生可能な曲が見つかりませんでした + 最初の %1$d アルバムのみキューに追加しました + %1$d アルバムをキューに追加しました(%2$d 曲) + 選択したアルバムをキューに追加できませんでした + すべての曲がすでにお気に入りにあります + お気に入りに曲がありませんでした + ZIP ファイルを作成中… + 共有に失敗しました: %1$s + + %d 曲をキューに追加しました + + + %d 曲が次に再生されます + + + %d 曲をお気に入りに追加しました + + + %d 曲をお気に入りから削除しました + + + + 共有するプレイリストがありません + プレイリストを共有 + 共有に失敗しました: %1$s + エクスポートするプレイリストがありません + エクスポートに失敗しました: %1$s + Music/PixelPlayer Exports + 設定で Gemini API キーを設定してください。 + プレイリストを復元しました + + %d 件のプレイリストを共有中 + + + %2$s に %1$d 件のプレイリストをエクスポートしました + + + + 無効なアルバム ID + アルバム ID が見つかりません + アルバムデータの読み込みエラー: %s + アルバムが見つかりません + + + 無効なアーティスト ID + アーティスト ID が見つかりません + アーティストデータの読み込みエラー: %s + アーティストが見つかりませんでした + + + 再生中の曲は削除できません + %1$d 件のファイルを削除しました(%2$d 件スキップ — 再生中) + %2$d 件中 %1$d 件のファイルを削除しました + ファイルの削除に失敗しました + ファイルを削除しました + ファイルを削除できないか、見つかりません + 削除をキャンセルしました + 曲を削除しますか? + %2$s の「%1$s」\n\nこの曲は端末から完全に削除され、元に戻せません。 + これらの曲は端末から完全に削除され、元に戻せません。 + + %d 件のファイルを削除しました + + + %d 曲を削除しますか? + + + + メタデータを更新しました + %1$d 曲を更新中… + %1$d 曲を正常に更新しました! + %1$d 曲を更新しました。失敗: %2$d 曲 + 歌詞を保存しました + 歌詞の保存に失敗しました + 保存できる歌詞がありません + 権限が拒否されました — ファイルを編集できません + 権限が拒否されました — 歌詞を保存できません + 権限が拒否されました — このファイルを編集できません + + + 設定で選択した AI プロバイダーの有効な API キーを設定してください。 + AI エラー: %s + 選択した AI プロバイダーはアカウントのクレジットまたはクォータが不足しているためリクエストを拒否しました。 + 選択した AI モデルは利用できなくなりました。PixelPlayer がサポート対象のモデルへ自動的に切り替えを試みました。 + AI がプロンプトに合う曲を見つけられませんでした。 + デイリーミックスのアイデアを書いてください + AI でデイリーミックスを更新しました + 更新できませんでした: %s + AI がこのミックスに合う曲を見つけられませんでした + diff --git a/app/src/main/res/values-ja/strings_player.xml b/app/src/main/res/values-ja/strings_player.xml new file mode 100644 index 0000000000..eae601ef39 --- /dev/null +++ b/app/src/main/res/values-ja/strings_player.xml @@ -0,0 +1,195 @@ + + + + プレイヤーを閉じる + 再生中 + クラウドストリーム + キャスト + Bluetooth + 本体再生 + 接続中… + キューを開く + + + 接続の準備 + キャスト・Bluetooth オーディオ・スピーカーを同期するために、PixelPlayer が近くのデバイスと現在の Wi‑Fi を確認できるよう許可してください。 + 近くのデバイス + 接続済み Bluetooth オーディオ機器の読み取りと制御に必要です。 + Wi‑Fi 用の位置情報 + Android では、互換性のあるキャストデバイスを検出するために Wi‑Fi ネットワーク(SSID)の共有に位置情報が必要です。 + アクセスを許可 + これらの権限はデバイスの相互接続(キャスト・近くのスピーカーの制御・オーディオ同期)にのみ使用します。 + デバイスを接続 + 近くをスキャン中 + キャストセッション + 接続中 + 接続済み + このスマートフォン + Bluetooth オーディオ + 本体再生 + 再生中 + 一時停止中 + デバイスの音量 + スマートフォンの音量 + %1$d/%2$d + バッテリー残量 + 音量レベル + 切断 + 接続性 + Wi-Fi または Bluetooth をオンにしてください + 接続を更新 + Wi-Fi + オフ + オン + 接続済み + Bluetooth + オフ + オン + 接続済み + 近くのデバイス + デバイスを更新 + 接続済み + 接続中 + 接続可能 + 利用可能 + 接続中... + デバイスを検索中… + テレビやスピーカーの電源が入っており、同じ Wi‑Fi ネットワークに接続されていることを確認してください。 + コントロール + デバイス + + + キャストメディアサーバー + デバイスにキャスト中 + キャストデバイスにメディアを配信中 + %1$s: %2$s + このオーディオフォーマットはキャスト中にシークするとセッションがクラッシュする可能性があるため、一時的に利用できません。 + + + スリープタイマー + タイマー + %1$d 分 + %1$d 分後にタイマーをセットしました。 + 1 回 + + %d 回 + + 再生回数: %1$s + 現在のトラックの終わり + トラックの終わりで再生を停止します。 + スイッチをオン + カスタム時間 + タイマーをキャンセル + トラックの終わり + タイマーをキャンセルしました。 + 再生中の曲がないため、トラック終了タイマーを有効にできません。 + 曲が %1$s から %2$s に変わったため、トラック終了タイマーを無効にしました。 + 前のトラック + 現在のトラック + カスタム時間を設定 + + + 次の曲 + キューはまだ空です。 + + %d 曲待機中 + + キュー + キューは空です。 + 曲を並び替え + シャッフルを切り替え + リピートを切り替え + スリープタイマー + その他の操作 + 現在の曲を探す + キューをクリア + キューをクリア + 現在再生中の曲以外をすべてキューから削除しますか? + プレイリストとして保存 + %1$s のキュー + 現在のキュー + 曲を削除 + 削除しました + プレイリストとして保存 + すべて選択解除 + プレイリスト名 + 含める曲を検索… + 「%1$s」に一致する曲はありません + + %d 曲を選択中 + + %1$s として保存 + プレイリスト名を入力 + プレイリストから削除 + %1$s のその他のオプション + + + 歌詞 + 歌詞を読み込み中… + 同期あり + テキストのみ + 歌詞オプション + −.5 + −.1 + +.1 + +.5 + 0s + %1$+.1f 秒 + + + 歌詞の検索に失敗しました + リモートからの歌詞取得に失敗しました + 接続がタイムアウトしました。インターネット接続を確認してください。 + ネットワークエラー。インターネット接続を確認してください。 + サーバーエラー(コード %d)。しばらくしてから再試行してください。 + + + 歌詞はすでに利用可能です。オンライン取得をスキップしました。 + 埋め込み歌詞が見つかりました。オンライン取得をスキップしました。 + ローカル(.lrc)歌詞が見つかりました。オンライン取得をスキップしました。 + + + 歌詞を保存 + AI で翻訳 + この歌詞にはすでに翻訳があります + この歌詞はすでにこの言語です + API が設定されていません + 歌詞の翻訳が完了しました! + 歌詞を翻訳中... + インポートした歌詞をリセット + 歌詞をリセットしますか? + この曲の歌詞をリセットしてもよろしいですか? + 表示 + 配置 + 左揃え + 中央揃え + 右揃え + コントロール + 同期を調整 + 同期コントロールを非表示 + ローマ字表記を表示 + 翻訳を表示 + 没入モードを一時解除 + 画面をオンに保つ + + + 歌詞を保存 + 保存するバージョンを選択してください: + 同期あり(タイムスタンプ付き) + テキストのみ + + + 歌詞をオンラインで検索しますか? + 歌詞の候補を表示 + 最初の候補を自動適用せず、常に選択画面を開く + 歌詞を検索中… + 歌詞が見つかりませんでした + 歌詞を自動で見つけられませんでした。タイトルやアーティスト名を編集して手動で検索できます。 + 曲名 + アーティスト(任意) + %d 件見つかりました + 同期あり + %1$s • %2$s + 歌詞提供元: + https://lrclib.net/ + diff --git a/app/src/main/res/values-ja/strings_screens.xml b/app/src/main/res/values-ja/strings_screens.xml new file mode 100644 index 0000000000..be88a28c03 --- /dev/null +++ b/app/src/main/res/values-ja/strings_screens.xml @@ -0,0 +1,244 @@ + + + + エラー: ジャンル ID がありません + + + 始めましょう! + ステップ %1$d / %2$d + 先に必要な権限を許可してください。 + 必要な権限をすべて許可してください。 + ようこそ + β + ベータ + セットアップを完了しましょう。 + メディアの権限 + 音楽ライブラリを構築するために、PixelPlayer がオーディオファイルへアクセスする必要があります。 + 権限が許可されました + メディア権限を許可 + 通知 + ロック画面や通知シェードから音楽を操作するために通知を有効にします。 + 通知を有効化 + バックアップはありますか? + PixelPlayer のバックアップがある場合は今すぐ復元することでこのデバイスのセットアップの大部分をスキップできます。 + バックアップをインポート + バックアップを確認中 + バックアップパッケージを確認中… + バックアップを復元中 + スキップ / あとで + バックアップを復元 + セットアップを完了する前にインポートする内容を確認してください。 + %2$d モジュール中 %1$d を選択中 + %1$s に作成 + %1$s からのバックアップ + バージョン不明 + 選択を復元 + 復元中 + 除外フォルダ + デフォルトではすべてのフォルダがスキャンされます。ライブラリ構築時に無視する場所を選択してください。 + 無視するフォルダを選択 + 先にストレージの権限を許可してください + アプリのテーマ + ライブラリの探索を始める前に好みの外観を選んでください。 + ダーク + PixelPlayer のデフォルトの Material 3 ダーク外観。 + ライト + アプリ全体のより明るい Material 3 外観。 + システムに合わせる + スマートフォンの現在の外観設定に合わせます。 + おすすめ + 後から 設定 > 外観 > アプリのテーマ で変更できます。 + ライブラリレイアウト + ライブラリのナビゲーション方法を選択してください。 + + コンパクトモード + 最小化されたピルナビゲーションを使用 + 標準のタブ行を使用 + + アルバム + アーティスト + 後から 設定 > 外観 > ライブラリナビゲーション で変更できます。 + アプリナビゲーション + ボトムナビゲーションバーのスタイルを選択してください。 + デフォルトスタイル + 角が丸いフローティングピル + 標準のフル幅バー + コーナー半径をカスタマイズ + 後から 設定 > 外観 > ナビバースタイル で変更できます。 + アラームとリマインダー + 任意ですが、スリープタイマーを使用して PixelPlayer を正確な時刻に停止させたい場合はおすすめです。 + 権限を許可 + バッテリー最適化 + 一部の Android 端末はバックグラウンドアプリを積極的に終了させます。予期しない再生の中断を防ぐために PixelPlayer のバッテリー最適化を無効にしてください。 + 最適化を無効化 + 準備完了! + 音楽を楽しむ準備ができました。 + + + 検索… + 検索 + 検索をクリア + 最近の検索 + すべてクリア + 履歴 + 検索履歴アイテムを削除 + 結果なし + 「%1$s」の検索結果はありません + 見つかりませんでした + 別の検索語またはフィルターを試してください。 + 結果が見つかりませんでした。 + ジャンルで探す + 利用可能なジャンルがありません。 + + + %1$s を再生 + %1$s を折りたたむ + %1$s を展開 + アーティスト画像を編集 + 写真を変更 + デフォルトに戻す + アーティストをシャッフル再生 + + + ディスク %d + %1$s のカバー + %1$s · %2$s + + + プレイリストが見つかりません。 + このプレイリストは空です。 + 「曲を追加」をタップして始めましょう。 + このフォルダに曲はありません。 + 曲を並び替え + その他のオプション + プレイリストのオプション + プレイリストを編集 + プレイリストを削除 + プレイリストを削除しますか? + このプレイリストを本当に削除しますか? + デフォルトトランジションを設定 + プレイリストをエクスポート + %1$s • %2$s + 再生する + 追加 + 曲を追加 + 削除 + 曲を削除 + 並び替え + 曲を並び替え + + + グローバルトランジション + プレイリストルール + 上書きされない限り、すべての再生ソースにこの設定が適用されます。 + この特定のプレイリストのデフォルト動作を設定します。 + アクティブ状態 + グローバルデフォルト + プレイリストデフォルト + グローバルに従う + カスタム上書き + カスタム上書き + 有効にするとこのプレイリストに特定のルールを設定できます。 + グローバルデフォルトを使用 + 変更を保存しました + トランジションスタイル + トラックのブレンド方法 + なし + クロスフェード + トランジションの長さ + %1$d 秒のオーバーラップ + トランジションをリセット + 現在の曲 + 次の曲 + トラックは %1$d 秒間オーバーラップします + 音量カーブ + オーディオのスロープを微調整 + フェードアウト + フェードイン + + + 新しいスマートプレイリスト + 新しいプレイリスト + 曲を追加 + 戻るまたはキャンセル + 次へ + 作成 + プレイリストを編集 + 自動生成コラージュ + 写真を追加 + 画像を選択 + 変更 + 削除 + プレイリスト名 + マイ素敵なミックス + カバーを編集 + カバーアートを調整 + ピンチとドラッグで最適なフレーミングを見つけてください + 手動 + スマート + AI で生成 + スマートルール + デフォルト + 画像 + アイコン + 背景色 + アイコンシンボル + 形状スタイル + 形状パラメーター + コーナー半径 + 滑らかさ + 辺の数 + カーブ + 回転 + スケール + よく再生する曲 + 最も再生されたトラック。 + 最近再生した曲 + 最近聴いた曲。 + 忘れられたお気に入り + しばらく再生していないお気に入りのトラック。 + 新着の宝石 + 再生回数が少ない最近追加されたトラック。 + + + ジャンルに曲を素早く追加 + 並び替えと再生 + シャッフル + 並び替え基準 + アーティスト + アルバム + タイトル + 一般アーティスト + %1$s シャッフル + + + 曲を選択 + ジャンルを選択 + 曲を検索 + 新しいジャンル + カスタムを追加 + カスタムジャンルを追加 + ジャンル名 + アイコンを選択 + ジャンル: %1$s + ジャンルを選択 + 素早く追加 + + + DJ スペース + 読み込み中… + デッキ %1$d + 曲を読み込む + 曲が読み込まれていません + + ステム分離はまだ利用できません。 + 音量 + 速度 + クロスフェーダー + デッキ 1 + デッキ 2 + 曲を選択 + 再生/一時停止 + 曲のカバー + x%1$.2f + diff --git a/app/src/main/res/values-ja/strings_settings.xml b/app/src/main/res/values-ja/strings_settings.xml new file mode 100644 index 0000000000..0492aa489a --- /dev/null +++ b/app/src/main/res/values-ja/strings_settings.xml @@ -0,0 +1,665 @@ + + + + 音楽管理 + フォルダ管理、ライブラリ更新、解析オプション + 外観 + テーマ、レイアウト、ビジュアルスタイル + 再生 + オーディオ動作、クロスフェード、バックグラウンド再生 + 歌詞 + 優先ソース、没入型歌詞、インポート管理 + 動作 + ジェスチャー、触覚フィードバック、ナビゲーション動作 + AI 連携(β) + AI プロバイダー、API キー、モデル設定 + 生成パラメータ + 温度、サンプリング、プロンプト、楽曲データ設定 + バックアップ & 復元 + 個人データのエクスポートと復元 + 開発者オプション + 試験的機能とデバッグ + イコライザー + 音域とプリセットの調整 + デバイス情報 + オーディオ仕様、コーデック、デコーダー情報 + アカウント + Telegram、Google Drive、NetEase などのサービスを管理 + このアプリについて + アプリ情報、バージョン、クレジット + + + オン + オフ + 有効 + 無効 + 開く + すべて選択 + 選択を解除 + 通知を閉じる + + + ライブラリ構造 + 除外ディレクトリ + ここに追加したフォルダはライブラリスキャン時にスキップされます。 + アーティスト + 複数アーティストの解析と整理オプション。 + フィルタリング + 最低曲の長さ + アルバムの最低トラック数 + アルバムアートキャッシュ上限 + 同期とスキャン + ライブラリを更新 + 新しいファイルや変更されたファイルをライブラリ全体からスキャンします。 + フルリスキャン + フルリスキャン実行中 + フルリスキャンを開始しました… + ライブラリ同期が完了しました + データベースを再構築 + データベースを再構築しますか? + 音楽ライブラリを最初から完全に再構築します。インポートした歌詞、お気に入り、カスタムメタデータはすべて失われます。この操作は元に戻せません。 + 再構築 + データベースを再構築中 + データベースを再構築中… + .lrc ファイルを自動スキャン + ライブラリ同期中に、同じフォルダ内の .lrc ファイルを自動でスキャンして割り当てます。 + 歌詞管理 + 歌詞ソースの優先順位 + 歌詞を取得する際に最初に試みるソースを選択します。 + 埋め込みを優先 + オンラインを優先 + ローカル(.lrc)を優先 + インポートした歌詞をリセット + データベースからインポートした歌詞をすべて削除します。 + インポートした歌詞をリセットしますか? + この操作は元に戻せません。 + + + 更新 + デフォルトではすべて許可されています。フォルダをタップするとスキャンから除外されます。 + サブフォルダがありません + 上へ移動 + ルートへ移動 + + + リスキャンが必要です + アーティスト設定が変更されました。ライブラリをリスキャンして適用してください。 + リスキャン + スキャン中… + 複数アーティストの解析 + 文字区切り + 現在: %1$s + 単語区切り + なし + 現在: %1$s + 設定 + タイトルからアーティストを抽出 + 曲タイトルの feat., ft., with を検出 + ライブラリ整理 + アルバムアーティストでグループ化 + コラボアルバムをメインアーティストの下に表示 + 複数アーティスト解析について + + PixelPlayer は文字区切り(/、;、&)と単語区切り(feat.、ft.、vs.、x)を使ってアーティストタグを分割します。単語区切りは大文字小文字を区別しません。 + 「タイトルからアーティストを抽出」は曲タイトルの (feat. アーティスト名) のようなパターンを検出します。 + バックスラッシュ(\)で文字区切りをエスケープできます。 + + + + + \"Artist1/Artist2\" + Artist1, Artist2 + \"Drake feat. Rihanna\" + Drake, Rihanna + \"Marshmello x Bastille\" + Marshmello, Bastille + \"Song (ft. B)\" by A + A, B + \"AC\\DC\" + AC/DC(エスケープ済み) + + + 区切り文字 + 現在の区切り文字 + 区切り文字をタップして削除します。少なくとも 1 つ必要です。 + 新しい区切り文字を追加 + 例: / または ; + 区切り文字を追加 + デフォルトの区切り文字 + 区切り文字をリセットしますか? + カスタム区切り文字をすべてクリアしてデフォルトに戻します。この操作は元に戻せません。 + 区切り文字をデフォルトにリセットしました + 少なくとも 1 つの区切り文字が必要です + 区切り文字を追加しました + すでに存在するか無効な区切り文字です + スペース + + + 単語区切り + 現在の単語区切り + スペースで囲まれているときにアーティスト名を分割するキーワードです。大文字小文字を区別しません。タップして削除。 + 単語区切りが設定されていません + 新しい単語区切りを追加 + 例: feat. または ft. + 単語区切りを追加 + 単語区切りの仕組み + 単語区切りはスペースで囲まれている場合に大文字小文字を区別せずマッチします。\n\n1文字の区切り(例: \"x\")は誤マッチを防ぐために両側にスペースが必要です。\n\n例:\n \"Drake feat. Rihanna\" -> Drake, Rihanna\n \"Marshmello x Bastille\" -> Marshmello, Bastille\n \"A vs. B\" -> A, B + 単語区切りをリセットしますか? + カスタム単語区切りをすべてクリアしてデフォルトキーワードに戻します。この操作は元に戻せません。 + 単語区切りを追加しました + すでに存在するか無効です + 単語区切りをデフォルトにリセットしました + + + 同期を準備中 + MediaStore を読み込み中 + トラックを処理中 + データベースに保存中 + 歌詞ファイルをスキャン中 + アルバムアートキャッシュをクリア中 + クラウドソースを同期中 + 同期を完了中 + %1$s • %2$d%% (%3$d/%4$d) + %1$s… + + + グローバルテーマ + アプリの言語 + アプリ全体で使用する言語を選択します。 + システムのデフォルト + English + Español + Deutsch + Français + Русский + 简体中文 + Bahasa Indonesia + Italiano + 한국어 + Norsk (Bokmål) + Türkçe + 日本語 + 繁體中文 + Tiếng Việt + アプリのテーマ + ライト、ダーク、またはシステムに合わせるを選択します。 + ライトテーマ + ダークテーマ + システムに合わせる + スムーズコーナーを使用 + 複雑な形状のコーナーを使用して見た目を向上させますが、ローエンド端末ではパフォーマンスに影響する場合があります。 + ブラー効果を無効化 + アプリ全体のブラー効果をオフにしてバッテリーとリソースを節約します。 + スクロールバーを表示 + 音楽リストにスクロールバーを表示してすばやくスクロールできます。 + 再生中 + プレイヤーテーマ + フローティングプレイヤーの外観を選択します。 + アルバムアート + システムダイナミック + プレイヤーのファイル情報を表示 + プレイヤーの進行バーにコーデック、ビットレート、サンプルレートを表示します。 + アルバムアートパレットスタイル + 現在: %1$s。ライブプレビューを開いてスタイルを選択してください。 + カルーセルスタイル + アルバムカルーセルの外観を選択します。 + のぞき込みなし + のぞき込み 1 枚 + ホームコラージュ + コラージュパターン + 「あなたのミックス」コラージュの形状を選択します。 + パターンを自動ローテーション + ホームを訪れるたびにコラージュパターンを切り替えます。 + ナビゲーションバー + ナビバースタイル + ナビゲーションバーの外観を選択します。 + デフォルト + フル幅 + コンパクトモード + アイコンのみ表示してナビバーの高さを縮小します。 + ナビバーのコーナー半径 + ナビゲーションバーのコーナー半径を調整します。 + 歌詞画面 + 没入型歌詞 + コントロールを自動非表示にしてテキストを拡大します。 + 自動非表示の遅延 + コントロールが非表示になるまでの時間。 + 3 秒 + 4 秒 + 5 秒 + 6 秒 + アプリナビゲーション + デフォルトタブ + 起動時のデフォルトタブを選択します。 + ホーム + 検索 + ライブラリ + ライブラリナビゲーション + ライブラリタブ間の移動方法を選択します。 + タブ行(デフォルト) + コンパクトピル & グリッド + + + カラー + パレットスタイル + プレイヤー UI のアルバムカラーを選択します。 + トーナルスポット + バランスが取れた落ち着いた雰囲気。 + ビビッド + 高彩度のアクセント。 + エクスプレッシブ + 大胆な色相シフトとコントラスト。 + フルーツサラダ + 楽しい回転アクセント。 + カラーの精度 + 0 は現在の調整を維持します。高い値ほどアルバムアートの主要色に近くなります。 + 現在 + より正確 + 0 • 現在 + %1$d • 穏やか + %1$d • バランス + %1$d • 正確 + + + コーナー半径を調整 + ナビバーの形状のコーナーをデバイスの物理コーナーに合わせてシームレスな外観にします。 + コーナー半径 + %1$d dp + + + バックグラウンド再生 + 閉じても再生を続ける + オフにすると、アプリを履歴から削除したときに再生が停止します。 + バッテリー最適化 + バッテリー最適化を無効にして再生の中断を防ぎます。 + バッテリー最適化はすでに無効になっています + バッテリー設定を開けませんでした + 音量 + 音量ゼロで一時停止 + 音量がゼロのときに再生を一時停止します + 音量ノーマライゼーション(ReplayGain) + ReplayGain を有効化 + オーディオファイルの ReplayGain メタデータを使って音量レベルを正規化します。 + ゲインモード + トラック: 曲ごとに正規化。アルバム: アルバム単位で正規化。 + トラック + アルバム + キャスト + キャスト接続/切断時に自動再生 + キャスト接続を切り替えた直後に自動で再生を開始します。 + ヘッドフォン + ヘッドフォン再接続時に再開 + ヘッドフォンを外したために一時停止した場合、再接続すると自動で再開します。 + キューとトランジション + クロスフェード + 曲間のスムーズなトランジションを有効にします。 + クロスフェードの長さ + Hi-Fi モード + 32 ビット float オーディオ出力。端末で再生がカクつく場合は無効にしてください。 + この端末ではサポートされていません(PCM_FLOAT AudioTrack 非対応)。 + シャッフルを保持 + アプリを閉じた後もシャッフル設定を記憶します。 + キュー履歴を表示 + キューに以前再生した曲を表示します。 + + + フォルダ + 戻るジェスチャーでフォルダを操作 + フォルダタブで、システムの戻る操作がライブラリを離れる前にフォルダ階層をさかのぼります。 + プレイヤーのジェスチャー + 背景タップでプレイヤーを閉じる + ぼかした背景をタップするとプレイヤーシートが閉じます。 + 触覚フィードバック + 触覚フィードバック + アプリ全体でバイブレーションフィードバックを有効にします。 + + + AI プロバイダー + プロバイダー + AI プロバイダーを選択してください + セーフトークンモード + ON — 高速 & 低コスト。AI に最小限のデータ(約 1K トークン)を送信します。 + OFF — 深いコンテキスト。より豊かな結果のためにリスニングプロフィール全体(約 8K トークン)を送信します。 + 認証情報 + %1$s API キー + %1$s から取得 + Google AI Studio (aistudio.google.com) + DeepSeek Platform (api.deepseek.com) + Groq Console (console.groq.com) + Mistral AI Platform (console.mistral.ai) + NVIDIA Build (build.nvidia.com) + Moonshot AI Platform (platform.moonshot.cn) + Zhipu AI Open Platform (bigmodel.cn) + OpenAI Platform (platform.openai.com) + モデル選択 + 利用可能なモデルを読み込み中… + モデルの読み込みに失敗しました + AI モデル + モデルを選択してください。 + API キーを入力 + プロンプト動作 + システムプロンプト + AI の動作をカスタマイズします。 + プリセットプロンプト + システムプロンプトを入力… + プロフェッショナルキュレーター + あなたは「Vibe-Engine」という世界トップクラスの音楽キュレーターで、ソニックフローの達人です。シームレスで高品質なリスニング体験を提供することが目標です。和声の相性、論理的な BPM トランジション、馴染みのお気に入りと洗練された発見のバランスを優先してください。 + クリエイティブマーベリック + あなたは「予期しない統一感」を専門とする前衛的な音楽探求者です。非自明なソニックの共通点を見つけることで従来のジャンルの壁を打ち破ることが使命です。レアなディープカット、実験的なテクスチャー、芸術的な新しさを優先しながら、驚きつつも否定できないトランジションロジックを維持してください。 + 厳格な司書 + あなたは精密な音楽データベースアーキテクトです。絶対的なメタデータの精度と厳格なカテゴリ遵守によってロジックを動かします。アルゴリズムによる発見を最小化し、厳格なジャンルの一貫性、エネルギーレベルのマッチング、ユーザーが明確に定義した好みの高精度な取得を最大化してください。 + アトモスフェリックガイド + あなたはアンビエントテクスチャーと低エネルギーフローの達人です。「深い集中」や「静けさ」の状態を促すトラックだけに集中してください。アコースティックな温かさ、ミニマリストのアレンジ、穏やかなトランジションを優先し、高い過渡音や急激なダイナミックの変化を厳しく避けてください。 + ソニックエンスージアスト + あなたはプロダクションの複雑さと演奏に焦点を当てたオーディオファイルアナリストです。高いダイナミックレンジ、複雑なポリリズム、優れたサウンドステージ品質を持つトラックを優先してください。技術的な忠実度とアレンジの細部に注意を払うリスナーを喜ばせるアクティブリスニング作品を選んでください。 + エナジーカタリスト + あなたは高モメンタムのリズムジェネレーターです。強烈なベースライン、パーカッシブな強度、感染力のあるグルーヴを中心哲学とします。高 BPM のクラブ互換性、シンコペーションエネルギー、継続的なリズムの張りを優先して、リスナーの心拍数とモチベーションをピーク状態に保ってください。 + AI 使用レポート + 総消費量 + %1$s トークンを追跡中\nプロンプト: %2$s | 出力: %3$s | 思考: %4$s + ログをクリア + AI アクティビティログ(%1$d 件) + %1$s · %2$s + 表示 + 非表示 + + + バックアップの仕組み + セクションを選んで .pxpl ファイルをエクスポートし、後でインポートして復元します。復元は選択したセクションのみを置き換えます。 + バックアップを作成 + バックアップをエクスポート + セクションが選択されていません。 + すべてのセクションが選択されています。 + %2$d セクション中 %1$d を選択中。 + %1$s .pxpl バックアップファイルを作成します。 + 選択してエクスポート + バックアップを復元 + バックアップをインポート + 選択して復元 + 最近のバックアップを参照または選択します。選択したデータが現在のデータを置き換えます。 + + + バックアップパッケージに含める内容を正確に選択してください。 + .pxpl バックアップファイルを選択して確認します。次のステップで復元するセクションを選択します。 + %2$d セクション中 %1$d を選択中 + %2$d モジュール中 %1$d を選択中 + 最近のバックアップ + 最近のバックアップはありません + 以前にインポートしたバックアップがここに表示されます。 + %1$d エントリー · 現在のデータを置き換えます + .pxpl をエクスポート + 選択を復元 + 転送中… + PixelPlayer_Backup_%1$d.pxpl + バックアップを作成中 + バックアップを復元中 + %1$d%% + %1$s • %2$s + エクスポート中 + インポート中 + 復元中 + 履歴から削除 + 確認中… + ファイルを参照 + ステップ %1$d / %2$d + モジュールを復元 + バックアップの詳細 + 作成日 + アプリバージョン + スキーマ + デバイス + 不明 + · %1$s + %1$d モジュール · v%2$s · スキーマ v%3$d + \? + すべて選択 + 選択をクリア + + + 無効なバックアップ: %1$s + 復元を準備中 + 復元タスクを開始しています。 + バックアップを準備中 + バックアップタスクを開始しています。 + バックアップを正常に復元しました + 一部の未解決の問題がありましたが復元は完了しました。 + 復元を完了できませんでした: %1$s + 復元に失敗しました: %1$s + データを正常にエクスポートしました + エクスポートに失敗しました: %1$s + データを正常に復元しました + 未解決の問題で復元が完了しました。失敗: %1$s + v%1$d + %1$s %2$s + + + 実験的機能 + 試験的 + プレイヤー UI 読み込みの実験とトグル。 + セットアップフローをテスト + テスト用にオンボーディングのセットアップ画面を起動します。 + メンテナンス + デイリーミックスの強制再生成 + デイリーミックスプレイリストをすぐに再作成します。 + デイリーミックスを再生成 + デイリーミックスを再生成しますか? + 現在のミックスを破棄して、最近のリスニング習慣に基づいて新しいミックスを生成します。 + デイリーミックスの再生成を開始しました + 統計の強制再生成 + キャッシュをクリアして再生統計を再計算します。 + 再生成 + 処理中… + 統計を再生成 + 統計を再生成しますか? + 統計キャッシュをクリアして、データベース履歴から強制的に再計算します。 + 統計の再生成を開始しました + アルバムパレットの強制再生成 + すべてのアルバムアートのキャッシュされたパレットバリアントを再構築するか、特定の 1 枚を更新します。 + すべて再生成 + すべてのアルバムパレットを再生成しますか? + キャッシュされたテーマデータをクリアして、%1$d 枚のユニークなアルバムアートのすべてのパレットスタイルを再構築します。 + 再生成中… + アルバムパレットを再生成中… + %1$d 枚のユニークなアルバムアートのキャッシュされたパレットバリアントを再構築中です。大きなライブラリでは時間がかかることがあります。 + %1$d / %2$d 完了 + %1$d 枚のアルバムアートパレットを再生成しました + %2$d 枚中 %1$d 枚のアルバムアートパレットを再生成しました + 曲を選択 + 曲を選択するとキャッシュされたテーマデータをクリアして、アルバムアートからすべてのパレットスタイルを再生成します。 + タイトル、アーティスト、アルバムで検索 + 検索に一致する曲がありません。 + アルバムアートのある曲が見つかりませんでした。 + パレットを再生成中… + %1$s のパレットを再生成しました + %1$s のパレットを再生成できませんでした + 診断 + テストクラッシュを発生させる + クラッシュレポートシステムをテストするためにクラッシュをシミュレートします。 + 開発者オプションからテストクラッシュを発生させました — これはクラッシュレポートシステムをテストするための意図的な操作です + + + 試験的 + プレイヤー UI 読み込みの調整 + アニメーション歌詞(ハイエンド端末向け) + 歌詞にスプリングアニメーションとビジュアル効果を使用します。ローエンド端末ではフレームドロップが発生する場合があります。 + 歌詞のブラー効果 + 非アクティブな歌詞に被写界深度ブラーを適用します。 + ブラー強度 + ブラー効果の強さを調整します。 + %1$.1f倍 + ステップ 1 · 遅延する対象を選択 + すべてを遅延 + シートの背景が完全に展開されるまでプレイヤーのコンテンツ全体を保持します。 + アルバムカルーセル + シートが展開されるまでアルバムアートとカルーセルを遅延します。 + 曲のメタデータ + タイトル、アーティスト、歌詞/キューのアクションを遅延します。 + 進行バー + 展開完了までタイムラインと時刻ラベルを遅延します。 + 再生コントロール + 再生/一時停止、シーク、お気に入りコントロールを遅延します。 + 遅延するコンポーネントがすべてアクティブです。「すべてを遅延」を無効にして各パーツをカスタマイズします。 + ステップ 2 · プレースホルダーの動作を設定 + 遅延項目にプレースホルダーを使用 + コンポーネントが展開を待つ間、軽量なプレースホルダーを描画してレイアウトを安定させます。 + ステップ 3 · プレースホルダーから実コンテンツに切り替えるタイミングを選択 + モードを 1 つ選択してください。閾値モードはスライダーを使用します。ドラッグリリースモードはシートジェスチャーを離すまで待機します。 + トリガーモードを解除するには遅延コンポーネントを少なくとも 1 つ有効にしてください。 + 閾値 + 展開率を使用します。 + ドラッグリリース + ジェスチャーを離した後のみ切り替えます。 + 展開閾値 + 遅延コンポーネントが表示されるまでにシートがどれだけ展開している必要があるか。 + コンテンツは %1$d%% 展開時に表示されます + プレイヤーを閉じるときにも適用 + 折りたたむ際に閉じる閾値を使ってプレースホルダーに戻します。 + 閉じる閾値 + プレースホルダーが再び表示されるまでにどれだけ折りたたまれている必要があるか。 + %1$d%% 折りたたみ後にプレースホルダーが表示されます + ドラッグリリースモードは閾値と閉じる動作をバイパスします。切り替えはシートのドラッグジェスチャーが終了したときのみ発生します。 + プレースホルダーを透明にする + プレースホルダーはレイアウトスペースを保持したまま見えなくなります。 + 画質 + アルバムアートの解像度 + 低(256px)- パフォーマンス重視 + 中(512px)- バランス型 + 高(800px)- 最高品質 + オリジナル - 最大品質 + + + 再生には確認が必要です + 再生の準備ができています + -- + フォーマット + HW デコーダー + ローカル曲 + ローカル音楽ストレージ + 音楽サイズ + %1$d 曲(ローカル) + 利用可能 + %1$s 合計 + 音楽の使用量 + デバイス使用中 + %1$d%% + <1% + %1$d 曲(クラウド) + %1$d ファイルは読み取り不可 + 再生パス + はい + いいえ + サンプルレート + %1$d Hz + %1$d フレーム/バッファ + Hi-Fi PCM Float + 32 ビット float 出力パス + 低レイテンシーサポート + プロオーディオサポート + メモリ + %1$s 中利用可能 + オフロード対応フォーマット + ハードウェアオフロードをサポートする圧縮フォーマットは報告されませんでした。 + 他 %1$d 件 + 検出された出力 + 内蔵出力 + Bluetooth オーディオ + USB オーディオ + 有線オーディオ + デジタル出力 + その他の出力 + Android から出力ルートは報告されませんでした。 + ExoPlayer エンジン + %1$s レンダラー + フォーマット互換性 + %1$d 対応トラック + %1$d 不明なフォーマット + デコーダーが報告されません + ハードウェアデコーダー + ソフトウェアデコーダー + オフロード + ライブラリ内 %1$d 件 + 互換性の確認結果 + 大きな非互換性はありません + インデックスされたトラックはこのデバイスで Android が報告するデコーダーと一致しています。 + %1$d 件のトラックはネイティブデコードできない可能性があります + 確認が必要なフォーマット: %1$s。 + %1$d 件のローカルトラックはリサンプリングされる可能性があります + ライブラリは現在の出力サンプルレートを超える %1$d Hz に達しています。 + %1$d 件のトラックはメタデータが不明です + ライブラリを完全にリスキャンすると MIME、ビットレート、サンプルレートの欠損データを補完できます。 + デバイス情報 + メーカー + モデル + ブランド + デバイス + Android バージョン + SDK バージョン + ハードウェア + パフォーマンスレポート + 再生やスキャンのラグを分類するのに役立つ共有可能な診断レポートを生成します。デバイス、ライブラリ、タイミングデータのみを含み、ファイルパス、タイトル、アーティストは含まれません。 + レポートを生成 + 再生成 + コピー + 共有 + レポートをクリップボードにコピーしました + PixelPlayer パフォーマンスレポート + 高度なパフォーマンス診断 + デフォルトではオフです。ベータのトラブルシューティング用に短いラグタイムラインを記録します。 + %1$s まで有効 + 今ラグをマーク + ラグの瞬間をマークしました + + + 接続済みアカウント + リンクされたプロバイダーを管理して各連携をコントロールします。 + リンク済みサービス + アクティブ + 利用可能 + 近日公開 + 接続済み + 近日公開 + サービスを開く + ログアウト中… + リンク済みアカウントがまだありません + プロバイダーを接続するとこの画面で管理できます。 + %1$s に接続 + %1$s(近日公開) + Google Drive は近日公開予定です。 + 現在この画面を開けません。 + + + このアプリについて + PixelPlayer + コミュニティと共に作られたオープンソースの音楽プレイヤー。 + GitHub + ソースコード、リリース、イシュー + Telegram + コミュニティに参加、開発をフォロー + Telegram に参加 + GitHub リポジトリを開く + バージョン v%1$s + オープンソース + コミュニティファースト + Material 3 エクスプレッシブ + 現在コントリビューターが見つかりません。後でもう一度お試しください。 + メンテナー + PixelPlayer の開発者。 + コミュニティスポットライト + 大きな貢献をしたコラボレーターへの感謝。 + ライセンスと通知 + 依存関係および以前の貢献への帰属表示。 + オープンソースライセンス + 依存ライセンスとサードパーティ通知 + オープンソースライセンス + MIT風通知 + サードパーティ通知 + 閉じる + バンドルされたサードパーティ通知を読み込めませんでした。 + オープンソースコントリビューター + GitHub からのライブコントリビューターリスト。 + %1$d 回のコントリビューション + GitHub プロフィールを開く + Telegram を開く + %1$s のアバター + %1$s のアイコン + diff --git a/app/src/main/res/values-ja/strings_widget.xml b/app/src/main/res/values-ja/strings_widget.xml new file mode 100644 index 0000000000..c0d6f47874 --- /dev/null +++ b/app/src/main/res/values-ja/strings_widget.xml @@ -0,0 +1,17 @@ + + + サイズに合わせて自動調整するウィジェット + コンパクトなプレイヤーバー + シャッフルとリピートを含むフルコントロール + ミニマリストな正方形プレイヤー + + タップして開く + アルバムアート + アルバムアートのプレースホルダー + + タップして再生 + 曲のタイトル + アーティスト + + 進行バー、%1$d%% + diff --git a/app/src/main/res/values-ko/plurals.xml b/app/src/main/res/values-ko/plurals.xml new file mode 100644 index 0000000000..da2e397128 --- /dev/null +++ b/app/src/main/res/values-ko/plurals.xml @@ -0,0 +1,45 @@ + + + + %d개의 재생목록을 공유하고 있습니다 + + + %1$d개의 재생목록을 %2$s로 내보냈습니다 + + + %d곡이 재생목록에 추가되었습니다 + + + %d곡이 다음에 재생됩니다 + + + %d곡이 즐겨찾기에 추가되었습니다 + + + %d곡이 즐겨찾기에서 제거되었습니다 + + + %d개의 파일이 삭제되었습니다 + + + %d개의 파일을 삭제하시겠습니까? + + + %d곡이 선택되었습니다 + + + %1$d곡 • %2$s + + + %d곡이 대기열에 추가되었습니다. + + + %d곡이 선택되었습니다 + + + %d곡 + + + %d개의 주제 + + diff --git a/app/src/main/res/values-ko/strings_changelogs.xml b/app/src/main/res/values-ko/strings_changelogs.xml index acb78fe7b1..81b43ec1ca 100644 --- a/app/src/main/res/values-ko/strings_changelogs.xml +++ b/app/src/main/res/values-ko/strings_changelogs.xml @@ -129,4 +129,22 @@ 현지화: 스페인어, 프랑스어, 러시아어, 중국어(간체), 인도네시아어, 이탈리아어 + + 플레이어 수명 주기 관리와 함께 Google Drive 통합. + 노래 메타데이터 일괄 편집(태그 및 앨범 아트). + 맞춤형 Wear OS 환경설정과 함께 AI 가사 번역. + 지연 진단 도구 및 검색 화면에서 다중 선택. + 아랍어 및 터키어 지원, 로컬 네트워크 옵션을 위한 지역화된 http URL. + + + 획기적인 배터리 절약(오디오 오프로드 및 UI 폴링 게이트). + 최적화된 대기열 관리(더 빠른 삽입 및 명시적 인덱싱). + 전환 화면을 위한 Material 3 Expressive 모션 애니메이션. + 조절된 스캐닝을 통한 리팩토링된 라이브러리 동기화. + + + 끊김/건너뜀 재생 지연 및 버퍼링 문제 해결. + 외부 노래 삭제 동기화 및 메타데이터 일관성 수정. + Wear OS 및 휴대폰에서 메모리 문제, 충돌 및 레이아웃 결함 수정. + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index 4b4fe3a87f..83554e05be 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -7,10 +7,14 @@ 테마, 레이아웃 및 시각적 스타일 재생 오디오 동작, 크로스페이드 및 백그라운드 재생 + 가사 + 소스 우선순위, 몰입형 가사 및 가져오기 관리 동작 제스처, 햅틱 및 내비게이션 동작 AI 연동 (β) - AI 제공업체, API 키 및 모델 설정 + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration 백업 및 복구 개인 앱 데이터 내보내기 및 복구 개발자 옵션 @@ -174,6 +178,9 @@ 한국어 노르웨이어 (Bokmål) 터키어 + 일본어 + 중국어(번체) + 베트남어 앱 테마 밝은 테마, 어두운 테마 또는 시스템 설정 따르기 중에서 선택하세요. 밝은 테마 @@ -630,6 +637,15 @@ PixelPlayer를 만드는 사람입니다. 커뮤니티 스포트라이트 큰 영향력을 미친 기여자들에 대한 감사의 표시입니다. + 라이선스 및 고지사항 + 의존성 및 이전 기여에 대한 저작권 표시. + 오픈 소스 라이선스 + 의존성 라이선스 및 서드파티 고지사항 + 오픈 소스 라이선스 + MIT 방식 고지사항 + 서드파티 고지사항 + 닫기 + 번들된 서드파티 고지사항을 불러올 수 없습니다. 오픈 소스 기여자 GitHub의 실시간 기여자 목록입니다. 기여 %1$d회 diff --git a/app/src/main/res/values-nb/plurals.xml b/app/src/main/res/values-nb/plurals.xml new file mode 100644 index 0000000000..f9b8355a09 --- /dev/null +++ b/app/src/main/res/values-nb/plurals.xml @@ -0,0 +1,59 @@ + + + + Delte %d spilleliste + Delte %d spillelister + + + Eksporterte %1$d spilleliste til %2$s + Eksporterte %1$d spillelister til %2$s + + + %d sang lagt til i køen + %d sanger lagt til i køen + + + %d sang skal spilles neste + %d sanger skal spilles neste + + + %d sang lagt til i favoritter + %d sanger lagt til i favoritter + + + %d sang fjernet fra favoritter + %d sanger fjernet fra favoritter + + + %d fil slettet + %d filer slettet + + + Slette %d fil? + Slette %d filer? + + + %d spor valgt + %d spor valgt + + + %1$d sang • %2$s + %1$d sanger • %2$s + + + %d spor satt opp. + %d spor satt opp. + + + %d sang valgt + %d sanger valgt + + + %d sang + %d sanger + + + %d emne + %d emner + + diff --git a/app/src/main/res/values-nb/strings_changelogs.xml b/app/src/main/res/values-nb/strings_changelogs.xml index c12abe6370..0c8ebb6711 100644 --- a/app/src/main/res/values-nb/strings_changelogs.xml +++ b/app/src/main/res/values-nb/strings_changelogs.xml @@ -129,4 +129,22 @@ Lokalisering: Spansk, Fransk, Russisk, Forenklet kinesisk, Indonesisk, Italiensk + + Google Drive-integrasjon med spillerens livssyklusstyring. + Batch-redigering av sangmetadata (tagger og coverkunst). + AI-tekstoversettelse med tilpassbare Wear OS-preferanser. + Forsinkelsesdiagnoseverktøy og flervalg på søkeskjermen. + Arabisk og tyrkisk støtte, med lokaliserte http-URL-alternativer for lokalnettverk. + + + Drastisk batteribesparelse (lydavlasting og UI-avstemningsporter). + Optimalisert køstyring (raskere innsettinger og eksplisitt indeksering). + Material 3 Expressive-bevegelsesanimasjoner for overgangsskjermer. + Omstrukturert biblioteksynkronisering via strupte skanninger. + + + Løst problemer med hakkete/hoppende avspillingsforsinkelser og bufringsproblemer. + Fikset synkronisering av eksterne slettede sanger og metadatakonsistens. + Fikset minneproblemer, krasjer og layoutfeil på Wear OS og telefon. + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index d8e2abe1fc..2b0ff05225 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -7,10 +7,14 @@ Temaer, layout og visuelle stiler Avspilling Lydatferd, crossfade og bakgrunnsavspilling + Lyrics + Source priority, immersive lyrics, and import management Atferd Gester, haptikk og navigasjonsatferd AI-integrasjon (β) - AI-leverandører, API-nøkler og modellinnstillinger + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Sikkerhetskopiering og gjenoppretting Eksporter og gjenopprett dine personlige app-data Utvikleralternativer @@ -174,6 +178,9 @@ Koreansk Norsk bokmål Tyrkisk + Japansk + Tradisjonell kinesisk + Vietnamesisk App-tema Bytt mellom lyst, mørkt eller følg systemets utseende. Lyst tema @@ -630,6 +637,15 @@ Personen bak PixelPlayer. Samfunns-spotlight Anerkjennelse for samarbeidspartnere med stor innvirkning. + Lisenser og varsler + Tilskrivning for avhengigheter og tidligere bidrag. + Åpen kildekode-lisenser + Avhengighetslisenser og tredjepartsvarsler + Åpen kildekode-lisenser + MIT-lignende varsel + Tredjepartsvarsler + Lukk + Den bun­dlede tredjeparts­vars­let kunne ikke lastes. Bidragsytere til åpen kildekode Live bidragsyterliste fra GitHub. %1$d bidrag. diff --git a/app/src/main/res/values-ru/plurals.xml b/app/src/main/res/values-ru/plurals.xml new file mode 100644 index 0000000000..09a9a4feda --- /dev/null +++ b/app/src/main/res/values-ru/plurals.xml @@ -0,0 +1,87 @@ + + + + Общий доступ к %d плейлисту + Общий доступ к %d плейлистам + Общий доступ к %d плейлистам + Общий доступ к %d плейлистам + + + Экспортирован %1$d плейлист в %2$s + Экспортировано %1$d плейлиста в %2$s + Экспортировано %1$d плейлистов в %2$s + Экспортировано %1$d плейлистов в %2$s + + + %d песня добавлена в очередь + %d песни добавлены в очередь + %d песен добавлено в очередь + %d песен добавлено в очередь + + + %d песня будет воспроизведена следующей + %d песни будут воспроизведены следующими + %d песен будут воспроизведены следующими + %d песен будут воспроизведены следующими + + + %d песня добавлена в избранное + %d песни добавлены в избранное + %d песен добавлено в избранное + %d песен добавлено в избранное + + + %d песня удалена из избранного + %d песни удалены из избранного + %d песен удалено из избранного + %d песен удалено из избранного + + + %d файл удалён + %d файла удалено + %d файлов удалено + %d файлов удалено + + + Удалить %d файл? + Удалить %d файла? + Удалить %d файлов? + Удалить %d файлов? + + + %d трек выбран + %d трека выбрано + %d треков выбрано + %d треков выбрано + + + %1$d песня • %2$s + %1$d песни • %2$s + %1$d песен • %2$s + %1$d песен • %2$s + + + %d трек поставлен в очередь. + %d трека поставлено в очередь. + %d треков поставлено в очередь. + %d треков поставлено в очередь. + + + %d песня выбрана + %d песни выбрано + %d песен выбрано + %d песен выбрано + + + %d песня + %d песни + %d песен + %d песен + + + %d тема + %d темы + %d тем + %d тем + + diff --git a/app/src/main/res/values-ru/strings_changelogs.xml b/app/src/main/res/values-ru/strings_changelogs.xml index 1e30b701ce..048bcadced 100644 --- a/app/src/main/res/values-ru/strings_changelogs.xml +++ b/app/src/main/res/values-ru/strings_changelogs.xml @@ -129,4 +129,22 @@ Локализация: испанский, французский, русский, упрощённый китайский, индонезийский, итальянский + + Интеграция с Google Drive с управлением жизненным циклом плеера. + Пакетное редактирование метаданных песен (теги и обложки). + Перевод текстов песен с помощью ИИ с настраиваемыми параметрами Wear OS. + Инструмент диагностики задержек и множественный выбор на экране поиска. + Поддержка арабского и турецкого языков, локализованные http URL для локальной сети. + + + Кардинальная экономия заряда батареи (выгрузка аудио и шлюзы опроса UI). + Оптимизированное управление очередью (более быстрая вставка и явная индексация). + Анимация Material 3 Expressive для экранов переходов. + Переработанная синхронизация библиотеки с регулируемым сканированием. + + + Устранены задержки воспроизведения (заикания, пропуски) и проблемы с буферизацией. + Исправлена синхронизация удаления внешних песен и согласованность метаданных. + Исправлены проблемы с памятью, сбои и ошибки макета на Wear OS и телефоне. + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index c49784964b..3128db38db 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -7,10 +7,14 @@ Темы, макеты и визуальные стили Воспроизведение Поведение аудио, кроссфейд и фоновое воспроизведение + Тексты песен + Приоритет источника, иммерсивный режим и управление импортом Поведение Жесты, тактильный отклик и навигация Интеграция ИИ (β) - Поставщики ИИ, API-ключи и настройки моделей + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Резервное копирование и восстановление Экспорт и восстановление персональных данных приложения Параметры разработчика @@ -174,6 +178,9 @@ Корейский Норвежский (Bokmål) Турецкий + Японский + Китайский (традиционный) + Вьетнамский Тема приложения Светлая, тёмная тема или настройки системы. Светлая тема @@ -630,6 +637,15 @@ Человек, стоящий за PixelPlayer. Гордость сообщества Признание участников, внёсших значительный вклад. + Лицензии и уведомления + Атрибуция для зависимостей и предыдущих вкладов. + Лицензии открытого ПО + Лицензии зависимостей и уведомления третьих лиц + Лицензии открытого ПО + Уведомление в стиле MIT + Уведомления третьих лиц + Закрыть + Не удалось загрузить встроенное уведомление третьей стороны. Участники open source Актуальный список участников с GitHub. %1$d вклад. diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 71b05a38e5..ed299517fd 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -10,7 +10,9 @@ Davranış Hareketler, dokunsal geri bildirim ve gezinme davranışı Yapay Zeka Entegrasyonu (β) - YZ sağlayıcıları, API anahtarları ve model ayarları + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Yedekle ve Geri Yükle Kişisel uygulama verilerinizi dışa aktarın ve kurtarın Geliştirici Seçenekleri @@ -174,6 +176,9 @@ Korece Norveççe (Bokmål) Türkçe + Japonca + Geleneksel Çince + Vietnamca Uygulama Teması Açık, koyu tema arasında geçiş yapın veya sistem görünümünü takip edin. Açık Tema diff --git a/app/src/main/res/values-vi/plurals.xml b/app/src/main/res/values-vi/plurals.xml new file mode 100644 index 0000000000..6a26e37fbd --- /dev/null +++ b/app/src/main/res/values-vi/plurals.xml @@ -0,0 +1,45 @@ + + + + Đang chia sẻ %d danh sách phát + + + Đã xuất %1$d danh sách phát tới %2$s + + + Đã thêm %d bài hát vào hàng đợi + + + %d bài hát sẽ phát tiếp theo + + + Đã thêm %d bài hát vào yêu thích + + + Đã xóa %d bài hát khỏi yêu thích + + + Đã xóa %d tệp + + + Xóa %d tệp? + + + Đã chọn %d bài hát + + + %1$d bài hát • %2$s + + + %d bài hát đã được xếp hàng. + + + Đã chọn %d bài hát + + + %d bài hát + + + %d chủ đề + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000000..82cb0250ef --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,128 @@ + + + PixelPlayer + Trình phát nhạc + Đổi tên ứng dụng + Chúng tôi đã đổi tên ứng dụng từ PixelPlay thành PixelPlayer do vấn đề liên quan đến nhãn hiệu. Tiếp tục thưởng thức nhạc! + Không hiển thị lại + + + Trang chủ + Tìm kiếm + Thư viện + + + Cần cấp quyền đặc biệt + Để chỉnh sửa siêu dữ liệu bài hát (tệp .mp3), PixelPlayer cần quyền truy cập đặc biệt vào tất cả tệp. Điều này cho phép chúng tôi chỉnh sửa trực tiếp thẻ bài hát. Vui lòng cấp quyền này ở màn hình tiếp theo để bật tính năng chỉnh sửa siêu dữ liệu. + Cấp quyền + + + Phát nhanh + Không thể mở tệp âm thanh này. + Mở trình phát đầy đủ + + + Phát ngẫu nhiên + Phát ngẫu nhiên tất cả bài hát + Phát ngẫu nhiên tất cả + Danh sách phát gần nhất + Không có danh sách phát nào để mở + + + Mở Google Play + Tiếp tục beta + Liên kết Google Play sẽ được kích hoạt từ cấu hình GitHub. + PixelPlayer hiện đã có trên Google Play + Sử dụng kênh ổn định trên Google Play để cập nhật bản phát hành trong khi chúng tôi giữ các bản beta hoạt động. + PixelPlayer + Thông báo phát hành + Sắp ra mắt + + + Cảm ơn bạn đã sử dụng PixelPlayer! + CAO %1$d + Đóng + ĐIỂM + CẤP %1$d + MẠNG + HOÀN THÀNH CẤP! + KẾT THÚC TRÒ CHƠI + Điểm: %1$d + Thử lại? + Cấp tiếp theo + Chơi lại + NHẤN ĐỂ KHỞI ĐỘNG LẠI + Phát nhạc ngẫu nhiên + Phá Gạch + ĐIỂM CAO %1$d + Chơi + Kéo để di chuyển thanh chắn + + + Đóng trình phát + Đang xử lý hành động phát lại… + Lỗi phát lại: %1$s + + + Quay lại + OK + Hủy + Bỏ qua + Lỗi + Tìm kiếm + Xóa tìm kiếm + Tất cả + Xác nhận + Đã lưu! + Đã chọn + %1$d%% + Nghệ sĩ + Chọn tất cả + Xóa + Lỗi không xác định + + + Lưu + Xong + Đặt lại + Áp dụng + Phát ngẫu nhiên + Sao chép + Chia sẻ + Hoàn tác + Nhập + Xóa + Xuất + Gộp + Đổi tên + Tạo + Lời bài hát + Cài đặt + Ảnh bìa album + Danh sách phát + Bài hát không xác định + Nghệ sĩ không xác định + Album không xác định + Đóng + Thêm + Xóa + Phát + Bài trước + Bài tiếp + Yêu thích + Tạm dừng + Lặp lại + Tùy chọn + Phát ngẫu nhiên + Thêm tùy chọn cho %1$s + Mở rộng menu + Tiếp theo + Hoàn thành + Đặt lại mặc định + Xuất tất cả + Gộp tất cả + Chia sẻ tất cả + Phát album + Phát ngẫu nhiên album + Ảnh bìa cho %1$s + diff --git a/app/src/main/res/values-vi/strings_changelogs.xml b/app/src/main/res/values-vi/strings_changelogs.xml new file mode 100644 index 0000000000..0c956f7d29 --- /dev/null +++ b/app/src/main/res/values-vi/strings_changelogs.xml @@ -0,0 +1,150 @@ + + + Nhật ký thay đổi + Xem trên GitHub + Cải tiến + Sửa lỗi + Có gì mới + Đã thêm + + Hỗ trợ Chromecast để truyền âm thanh từ thiết bị. + Nhật ký thay đổi trong ứng dụng để cập nhật tính năng mới nhất. + Hỗ trợ tệp .LRC, cả nhúng và bên ngoài. + Hỗ trợ lời bài hát ngoại tuyến. + Lời bài hát đồng bộ (đồng bộ với bài hát). + Màn hình mới để xem hàng đợi đầy đủ. + Sắp xếp lại và xóa bài hát khỏi hàng đợi. + Cử chỉ mini-player (vuốt xuống để đóng). + Thêm nhiều hoạt ảnh material hơn. + Cài đặt mới để tùy chỉnh giao diện. + Cài đặt mới để xóa cache. + + + Thiết kế lại hoàn toàn giao diện người dùng. + Thiết kế lại hoàn toàn trình phát. + Cải tiến hiệu suất trong thư viện. + Cải thiện tốc độ khởi động ứng dụng. + AI giờ cung cấp kết quả tốt hơn. + + + Sửa các lỗi trong trình chỉnh sửa tag. + Sửa lỗi thông báo phát không bị xóa. + Sửa nhiều lỗi gây crash ứng dụng. + + + Giới thiệu trung tâm thống kê nghe phong phú hơn với phân tích sâu hơn về các phiên. + Phát hành trình phát nhanh nổi để mở và xem trước tệp cục bộ ngay lập tức. + Thêm tab thư mục với trình điều hướng kiểu cây và chế độ xem sẵn sàng cho playlist. + + + Hoàn thiện giao diện Material 3 tổng thể để có trải nghiệm sạch sẽ và nhất quán hơn. + Chỉnh sửa metadata giờ hỗ trợ thay đổi ảnh bìa. + Làm mượt hoạt ảnh và chuyển tiếp trên toàn ứng dụng để điều hướng mượt mà hơn. + Cải thiện bố cục màn hình nghệ sĩ với chi tiết phong phú hơn. + Nâng cấp tạo DailyMix và YourMix với lựa chọn thông minh và đa dạng hơn. + Tăng cường tạo playlist AI. + Cải thiện relevance và trình bày tìm kiếm để khám phá nhanh hơn. + Mở rộng hỗ trợ cho nhiều định dạng tệp âm thanh hơn. + + + Giải quyết các vấn đề metadata để chi tiết bài hát luôn chính xác. + Khôi phục shortcut thông báo để chúng đáng tin cậy quay lại phát. + + + Thiết kế lại điều hướng chính + Trình khám phá tệp mới để chọn thư mục nguồn + Chức năng kết nối và truyền mới + Tiếp nối liền mạch giữa các thiết bị từ xa + Chuyển tiếp không khoảng cách giữa các bài hát + Điều khiển crossfade + Tính năng Chuyển tiếp tùy chỉnh mới (chỉ cho playlist) + Tiếp tục phát sau khi đóng ứng dụng + Tối ưu hóa giao diện + Cải thiện tính năng thống kê + Thiết kế lại điều khiển hàng đợi với nhiều tính năng hơn + Cải thiện hỗ trợ các loại tệp khác nhau để phát và chỉnh sửa metadata + Cải thiện bộ điều khiển quyền + Sửa lỗi nhỏ + + + Cập nhật giao diện Material 3 Expressive + Equalizer 10 dải & Hiệu ứng + Luồng đồng bộ thư viện mới + Tích hợp AI (Gemini Models) + Nhập/Xuất playlist M3U + Tích hợp ảnh nghệ sĩ Deezer + Ảnh bìa playlist tùy chỉnh + Thiết kế lại kiến trúc cài đặt + Hoạt ảnh Hàng đợi & Trình phát + Baseline Profiles & Hiệu suất + Hệ thống lời bài hát tốt hơn với bù đồng bộ + + + Cải thiện ổn định truyền + Ổn định_sheet trình phát + Sửa lỗi chung & Dọn dẹp + + + Hỗ trợ Android Auto hiện có sẵn để phát trong xe. + Hỗ trợ Wear OS đã hoạt động, bao gồm điều khiển phát tốt hơn từ đồng hồ. + Tích hợp đám mây mở rộng với cải tiến Telegram, NetEase, QQ Music và Google Drive. + Phát gần đây và khôi phục hàng đợi liên tục giúp phiên nghe của bạn sẵn sàng. + Sao lưu & Khôi phục v3 và công cụ quản lý tài khoản đã được bao gồm. + Lời bài hát thông minh hơn với tìm kiếm thủ công và cải thiện lưu trữ. + + + Đợt cải thiện hiệu suất lớn trên khởi động, thư viện, hàng đợi và tương tác trình phát. + Trình phát, Truyền, Lời bài hát, Nghệ sĩ và Thể loại được thiết kế lại để sử dụng mượt mà hơn. + Điều hướng và luồng tìm kiếm đáng tin cậy hơn, với xử lý tuyến đường an toàn hơn. + Cải thiện khả năng tương thích phát âm thanh cho nhiều thiết bị và định dạng hơn. + Mở rộng quy trình đa chọn cho bài hát, album và playlist. + + + Hành vi hàng đợi và phát ngẫu nhiên giờ ổn định và dự đoán được hơn. + Giải quyết nhiều trường hợp phát nền và truyền ngoại lệ. + Sửa lỗi Hẹn giờ ngủ, điều hướng tab Tệp và lỗi crash artist album. + Cải thiện tải widget và ổn định dịch vụ để giảm quá nhiệt/vấn đề bộ nhớ. + Sửa lỗi chung và hoàn thiện giao diện trên toàn ứng dụng. + + + Wear OS: Chuyển nhạc, phát cục bộ, đồng bộ hàng đợi và điều khiển từ xa từ đồng hồ. + AI: Tích hợp Groq AI và OpenRouter (thử nghiệm) với tối ưu hóa token. + Đám mây: Thêm hỗ trợ Jellyfin. + Lời bài hát: Dịch đồng bộ với nút chuyển đổi riêng, hỗ trợ định dạng Kugou LRC, tùy chỉnh căn chỉnh văn bản và cải thiện tải từ xa. + Giao diện/Trải nghiệm: Chế độ thanh điều_navigation thu gọn, theme động từ bảng màu ảnh bìa, marquee cho tiêu đề dài và tùy chọn sắp xếp mới. + Telegram: Hỗ trợ chủ đề gốc và chế độ hiển thị nâng cao. + + + Động cơ âm thanh: Đại tu hoàn toàn với hỗ trợ nhiều định dạng hơn (MIDI, ALAC, M4A) và tối ưu hóa bộ giải mã. + Hiệu quả: Giảm tiêu hao năng lượng mạnh, sửa lỗi quá nhiệt và tối ưu hóa tác vụ nền (SyncWorker). + Cơ sở dữ liệu: Tối ưu hóa query lớn và thiết kế lại cache ảnh bìa để ngăn mất dữ liệu. + Khởi động: Cải thiện thời gian tải qua tối ưu hóa Baseline Profile. + + + Phát: Sửa lỗi giật cục bộ trong Opus/MP3, lỗi ReplayGain khi crossfade và lỗi khởi động trên bộ giải mã Samsung. + Ổn định: Loại bỏ crash khi khởi động, điều hướng nghệ sĩ và trên thiết bị Android 12+. + Giao diện: Sửa lỗi nhấp nháy ảnh bìa, tràn văn bản trong chữ viết phi Latin và hành vi thanh điều hướng/miniplayer. + Bảo mật: Tăng cường xử lý thông tin đăng nhập, quyền lưu trữ và giao tiếp media server. + + + Địa phương hóa: Tiếng Tây Ban Nha, Tiếng Pháp, Tiếng Nga, Tiếng Trung giản thể, Tiếng Indonesia, Tiếng Ý + + + Tích hợp Google Drive với quản lý vòng đời trình phát. + Chỉnh sửa hàng loạt metadata bài hát (tag và ảnh bìa). + Dịch lời bài hát AI với tùy chỉnh Wear OS. + Công cụ chẩn đoán lag và đa chọn trên màn hình Tìm kiếm. + Hỗ trợ tiếng Ả Rập & Thổ Nhĩ Kỳ, với tùy chọn mạng cục bộ http URL được địa phương hóa. + + + Tiết kiệm pin mạnh (tải âm thanh ngoại vi và kiểm soát polling giao diện). + Tối ưu hóa quản lý hàng đợi (chèn nhanh hơn và đánh chỉ mục rõ ràng). + Hoạt ảnh chuyển động Material 3 Expressive cho màn hình chuyển tiếp. + Thiết kế lại đồng bộ thư viện qua quét được kiểm soát. + + + Giải quyết các vấn đề lag giật/bỏ qua phát và buffering. + Sửa đồng bộ xóa bài hát bên ngoài và nhất quán metadata. + Sửa vấn đề bộ nhớ, crash và lỗi bố cục trên Wear OS và điện thoại. + + diff --git a/app/src/main/res/values-vi/strings_cloud_services.xml b/app/src/main/res/values-vi/strings_cloud_services.xml new file mode 100644 index 0000000000..33db5f6580 --- /dev/null +++ b/app/src/main/res/values-vi/strings_cloud_services.xml @@ -0,0 +1,228 @@ + + + + Đăng nhập Telegram + Bạn đang chỉnh sửa số điện thoại. Gửi lại mã sẽ thay thế mã trước đó. + Đang xử lý… + Đang khởi tạo Telegram… + Đang đăng xuất… + Đang đóng phiên… + Đã đóng phiên. Mở lại đăng nhập để tiếp tục. + Đang chuẩn bị phiên Telegram bảo mật… + Đang chờ phản hồi từ Telegram… + Kết nối Telegram + Kết nối Telegram để phát nhạc từ các kênh và cuộc trò chuyện của bạn. + Số điện thoại + Nhập số Telegram của bạn. Bạn có thể quay lại chỉnh sửa sau. + Số điện thoại + 1 + 5551234567 + Gửi mã + Mã xác minh + Nhập mã từ Telegram. Nếu số sai, hãy quay lại chỉnh sửa. + + 12345 + Chỉnh sửa số điện thoại + Gửi lại mã + Xác minh mã + Mật khẩu hai bước + Nhập mật khẩu Telegram của bạn. Bạn vẫn có thể quay lại để sửa số điện thoại. + Mật khẩu + Xác minh mật khẩu + Vui lòng chờ… + + + Kênh Telegram + Thêm kênh + Kênh Telegram công khai + Đang đồng bộ + Đồng bộ ngay + Thu gọn chủ đề + Hiện chủ đề + Tùy chọn kênh + Chủ đề + Đang đồng bộ kênh + Đang cập nhật bài hát từ Telegram + Lấy bài hát mới nhất từ kênh này + Xóa kênh + Dừng đồng bộ và xóa bài hát đã lưu + Xóa kênh? + %1$s sẽ dừng đồng bộ và tất cả bài hát đã lưu từ kênh này sẽ bị xóa. + Xóa + Chưa đồng bộ kênh nào + Thêm kênh Telegram công khai để đồng bộ\nthư viện nhạc của bạn + Thêm kênh + Chưa đồng bộ + Đã đồng bộ %1$s + + + Thêm kênh + Tìm kiếm kênh Telegram công khai để đồng bộ nhạc + \@channelname hoặc link + Đang tìm kiếm… + Tìm kiếm kênh + Nhập tên người dùng hoặc link kênh công khai\nde đồng bộ các tệp âm thanh + + + %d bài hát + %d bài hát + + + %d chủ đề + %d chủ đề + + + + Subsonic + Quản lý Navidrome, Airsonic và các server tương thích Subsonic khác. + + + Nhấn đồng bộ để lấy playlist Jellyfin của bạn + Quản lý kết nối server Jellyfin của bạn. + + + Thư mục nhạc + Nhấn + để thêm thư mục Drive + Chưa thêm thư mục nào + %1$d thư mục đã đồng bộ + Thêm thư mục + + + Chọn loại playlist + Chọn playlist muốn đồng bộ: + Tất cả playlist + Tạo và thu thập + Playlist đã tạo + Playlist đã thu thập + + + %1$d playlist đã đồng bộ + Playlist + Đồng bộ + Chưa đồng bộ playlist nào + Nhấn đồng bộ để lấy playlist của bạn + Thao tác nhanh + Đồng bộ thư viện + Ngắt kết nối + %1$d bài hát + + + Đang đồng bộ + Đang đồng bộ thư viện… + Đang lấy playlist… + Đang đồng bộ playlist: %1$s + Đang cập nhật thư viện cục bộ… + Đồng bộ hoàn tất + Đang lấy danh sách album… + Đang lấy bài hát từ %1$s… + Đang lưu %1$d bài hát vào cơ sở dữ liệu… + Không tìm thấy bài hát trong thư viện + Đồng bộ thư viện hoàn tất + Đang đồng bộ… + Lỗi: %1$s + + + Đồng bộ + Đồng bộ tất cả + Đăng xuất + Đồng bộ tất cả playlist + Ảnh đại diện người dùng + + + Không có kết nối internet + Nội dung này yêu cầu kết nối internet. Vui lòng kiểm tra cài đặt mạng và thử lại. + Bạn đang ngoại tuyến + Vui lòng kiểm tra kết nối internet và thử lại để truy cập nội dung này. + + + Kết nối + Đang kết nối… + Nhập URL server và thông tin đăng nhập tài khoản. + Chi tiết kết nối + Ẩn mật khẩu + Mật khẩu + Nhập mật khẩu + Điền sẵn http:// + URL server + Hiện mật khẩu + Telegram + Tên người dùng + admin + Chào mừng, %1$s! + + + Tương thích với Navidrome, Gonic, Airsonic và các server tương thích Subsonic khác + Hỗ trợ Navidrome, Airsonic, Gonic, Ampache và các server tương thích với API Subsonic. + Mật khẩu ứng dụng cũng hoạt động nếu server của bạn hỗ trợ. + Điền sẵn https:// + Kết nối với server nhạc tự host của bạn + Navidrome + Sử dụng địa chỉ https:// đầy đủ của server. + https://music.example.com + Đây là tên tài khoản Subsonic hoặc Navidrome của bạn. + Subsonic / Navidrome + Subsonic + + + Nhập URL server Jellyfin và thông tin đăng nhập tài khoản. + Kết nối với server Jellyfin để phát nhạc từ thư viện của bạn + Kết nối với server Jellyfin. Cả HTTP và HTTPS đều được hỗ trợ cho truy cập mạng cục bộ. + Jellyfin + Mật khẩu tài khoản Jellyfin của bạn. + Kết nối với server đa phương tiện Jellyfin của bạn + Jellyfin + URL đầy đủ của server Jellyfin, bao gồm cổng. + http://192.168.1.100:8096 + Tên người dùng tài khoản Jellyfin của bạn. + + + Phát tệp nhạc trực tiếp từ Google Drive của bạn + Kết nối Google Drive + Đã kết nối Google Drive! + Tạo \"PixelPlayer Music\" + Tạo thư mục mới tại đây cho nhạc của bạn + Không có thư mục nào ở đây + Mở thư mục + Chọn hoặc tạo thư mục để sử dụng làm nguồn nhạc + Chọn thư mục nhạc + Đang thiết lập Google Drive… + Đăng nhập bằng Google + Google Drive + Sử dụng + + + Không thể đọc cookie phiên. + Xong + Thoát + Không tìm thấy cookie. Hãy đăng nhập trước. + Trang tải quá chậm. Hãy thử làm mới hoặc đổi mạng. + + + Đang lưu… + Ở lại + Tải trang bị timeout. Bạn có thể thử lại mà không mất tiến trình. + Quay lại web + Bạn có thể quay lại sau. Trạng thái trang hiện tại sẽ bị xóa khi đóng. + Tiến lên web + Làm mới + Thử lại + Mở trang chủ + Tải WebView thất bại. + + + Không thể đọc cookie NetEase: %1$s + Thoát đăng nhập NetEase? + HTTP %1$d khi tải NetEase. + Chưa phát hiện đăng nhập. Hoàn tất đăng nhập NetEase trước khi nhấn Xong. + Đăng nhập NetEase Music + Lưu ý bảo mật: Mật khẩu của bạn chỉ được nhập trên các trang web NetEase. PixelPlayer lưu cookie phiên (MUSIC_U) để đồng bộ thư viện. + NetEase Music + + + Không thể đọc cookie QQ Music: %1$s + Thoát đăng nhập QQ Music? + HTTP %1$d khi tải QQ Music. + Chưa phát hiện đăng nhập. Hoàn tất đăng nhập QQ Music trước khi nhấn Xong. + Đăng nhập QQ Music + Lưu ý bảo mật: Mật khẩu của bạn chỉ được nhập trên các trang web QQ Music. PixelPlayer lưu cookie phiên để đồng bộ thư viện. + QQ Music + diff --git a/app/src/main/res/values-vi/strings_equalizer.xml b/app/src/main/res/values-vi/strings_equalizer.xml new file mode 100644 index 0000000000..e1ebf496a4 --- /dev/null +++ b/app/src/main/res/values-vi/strings_equalizer.xml @@ -0,0 +1,57 @@ + + + + Tên không được để trống + Đổi tên + + + Đổi chế độ xem + Tắt equalizer + Bật equalizer + Chỉnh sửa + Chỉnh sửa preset + Preset tùy chỉnh + Preset + Cập nhật + Tăng cường bass + Virtualizer + Độ to + Không hỗ trợ + Không hỗ trợ trên thiết bị này + Âm lượng + Phản hồi tần số + Hz + Bass + Trung thấp + Trung cao + Treble + Bass / Thấp + Trung / Cao + Trang %1$d + Đặt lại thời lượng + Lưu mới + + + Preset đã lưu + Chưa lưu preset tùy chỉnh nào. + Bỏ ghim + Ghim + Đổi tên + Xóa + + + Lưu preset tùy chỉnh + Nhập tên cho preset equalizer tùy chỉnh của bạn. + Tên preset + Đổi tên preset + + + Quản lý preset + Kéo để sắp xếp lại • Nhấn mắt để hiện hoặc ẩn + Sắp xếp lại + Đặt lại preset + Thao tác này sẽ khôi phục thứ tự và hiển thị preset mặc định. Tiếp tục? + Đặt lại mặc định + Hiển thị + Ẩn + diff --git a/app/src/main/res/values-vi/strings_home_screen.xml b/app/src/main/res/values-vi/strings_home_screen.xml new file mode 100644 index 0000000000..5a995b8990 --- /dev/null +++ b/app/src/main/res/values-vi/strings_home_screen.xml @@ -0,0 +1,277 @@ + + + + β + Beta + Phát trực tuyến đám mây + Nhật ký thay đổi + Phát trực tuyến đám mây + Phát nhạc từ tài khoản đám mây của bạn + + + Beta 0.7.5 + β + Chào mừng đến với PixelPlayer 0.7.5-beta + Bạn đang sử dụng bản beta có thể chứa lỗi, sự cố hoặc các tính năng thử nghiệm. Hãy giúp chúng tôi cải thiện bằng cách báo cáo vấn đề. + Những gì cần mong đợi + Lỗi, sự cố hoặc các tính năng chưa hoàn thiện có thể xảy ra bất ngờ. + Một số tính năng có thể thay đổi hoặc bị xóa mà không thông báo. + Các bản beta có thể không ổn định hơn bản phát hành chính thức. + Luôn kiểm tra bản cập nhật trước khi báo cáo vấn đề đã biết. + Những gì bản beta có thể thay đổi, gây lỗi hoặc cải thiện trong quá trình thử nghiệm. + Shortcut vấn đề GitHub + Tìm kiếm trước, sau đó mở báo cáo tập trung cho lỗi, sự cố, yêu cầu hoặc câu hỏi. + Mở các vấn đề hiện có + Báo cáo vấn đề hoặc sự cố + Chia sẻ các bước tái tạo, kết quả mong đợi, kết quả thực tế và thông tin thiết bị/hệ điều hành của bạn. + Cách báo cáo + Danh sách kiểm tra nhanh trước khi mở vấn đề mới. + Trước khi mở vấn đề + Tìm kiếm các vấn đề đã mở và đã đóng để tránh trùng lặp. + Cập nhật lên phiên bản PixelPlayer mới nhất và xác nhận vấn đề vẫn xảy ra. + Khởi động lại ứng dụng và xác nhận vấn đề vẫn tồn tại. + Thử tái tạo vấn đề và ghi lại các bước chính xác. + Loại vấn đề nào? + Báo cáo lỗi: Một thứ gì đó hoạt động không chính xác. + Yêu cầu tính năng: Thêm tính năng mới hoặc cải tiến. + Câu hỏi: Sử dụng Thảo luận nếu được bật, hoặc mở vấn đề với nhãn câu hỏi. + Báo cáo lỗi + Sao chép các trường này khi có thứ gì đó hoạt động sai hoặc bị crash. + Báo cáo lỗi + Tóm tắt ngắn: + Hành vi mong đợi: + Hành vi hiện tại: + Các bước tái tạo: 1. 2. 3. + Tần suất xảy ra? Luôn luôn / Đôi khi / Hiếm khi. + Ảnh chụp màn hình / video: nếu có. + Nhật ký / stack trace: nếu có. + Môi trường + Phiên bản PixelPlayer: + Nguồn cài đặt: GitHub Release, bản debug, bản nightly, v.v. + Phiên bản Android: + Mẫu thiết bị: + Thông tin bổ sung: Sử dụng thẻ SD, cài đặt đặc biệt, quyền hạn, v.v. + Yêu cầu tính năng + Sao chép các trường này khi bạn muốn có tính năng mới hoặc cải tiến. + Mô tả vấn đề: Bạn đang cố gắng giải quyết vấn đề gì? + Giải pháp đề xuất: Nó nên hoạt động như thế nào? + Các giải pháp thay thế đã xem xét: Có cách tiếp cận nào khác không? + Phạm vi: Màn hình hoặc luồng nào bị ảnh hưởng? + Bản thiết kế hoặc hình ảnh tham khảo nếu có. + Tiêu đề, quyền riêng tư và phạm vi + Làm cho báo cáo dễ phân loại và an toàn để chia sẻ. + Tiêu đề vấn đề tốt + Equalizer: Chỉ báo dịch chuyển khi chuyển tab preset + Tìm kiếm: Danh sách lịch sử không hiển thị cho truy vấn trống + Tính năng: Thêm tùy chọn sắp xếp playlist \"Mới thêm\" + Vui lòng tránh + Các báo cáo chung chung như \"Nó không hoạt động\". + Nhiều vấn đề không liên quan trong một vấn đề. + Nhật ký hoặc ảnh chụp màn hình chưa chỉnh sửa với dữ liệu riêng tư. + Lưu ý quyền riêng tư + Trước khi đăng nhật ký, ảnh chụp màn hình hoặc video, hãy xóa thông tin cá nhân hoặc riêng tư. + Các bản nightly + Các bản nightly khác với bản phát hành như thế nào, và cần bao gồm gì khi chúng gặp sự cố. + Các bản nightly được tạo từ commit mới nhất và có thể chứa các thay đổi chưa hoàn thành, lỗi tạm thời hoặc sự suy giảm. Chúng mang tính thử nghiệm hơn các bản phát hành chính thức. + Truy cập chúng từ các artifact của quy trình GitHub Actions trong kho lưu trữ nếu có. + Báo cáo vấn đề nightly + Khi báo cáo vấn đề từ bản nightly, luôn đề cập rằng nó xảy ra trên bản nightly, không phải trên bản phát hành chính thức. Bao gồm ngày build, tên hoặc số quy trình chạy, hoặc commit SHA nếu có thể. Cũng kiểm tra xem vấn đề tương tự có xảy ra trên bản phát hành chính thức mới nhất không. + Nâng cấp Beta 0.5.0 + Khuyến nghị cài đặt sạch + Nếu bạn đến từ beta 0.5.0, bản cập nhật này có thể cần dữ liệu thư viện mới thay vì trạng thái cache cũ. + Nếu metadata hoặc mục thư viện hiển thị sai + Metadata bài hát sai, nghệ sĩ hoặc album không khớp, hoặc các mục bị trùng lặp thường có nghĩa là cài đặt sạch là giải pháp. + Không hiển thị lại + Đã hiểu + + + Ối! Có gì đó không ổn + Ứng dụng đã bị crash trong phiên làm việc cuối. Hãy giúp chúng tôi sửa lỗi này bằng cách chia sẻ báo cáo sự cố. + Ngày: %1$s + Lỗi: + Stack trace (xem trước): + Nhật ký sự cố + Nhật ký sự cố đã được sao chép vào clipboard + Báo cáo sự cố PixelPlayer + Chia sẻ báo cáo sự cố + + + DJ Mashup + + + Phối\ncủa bạn + Chưa có dữ liệu hiển thị + Phối của bạn sẽ xuất hiện ở đây khi PixelPlayer tìm thấy bài hát hoặc đồng bộ nguồn. + Làm mới + + + PHỐI HÀNG NGÀY + Dựa trên lịch sử + Xem tất cả Phối hàng ngày + Phối hàng ngày + + + Phối hàng ngày + + %1$d bài hát • %2$s + %1$d bài hát • %2$s + + Phát ngay + Trình tạo playlist AI + + + Phối hàng ngày được tạo như thế nào + Phối hàng ngày được tạo từ các bài hát yêu thích và được phát nhiều nhất của bạn. Chúng tôi cũng thêm các bản nhạc từ nghệ sĩ và thể loại bạn thích để bạn có thể khám phá nhạc mới. + Hãy cho AI biết bạn muốn nghe gì hôm nay + Chúng tôi sử dụng mẫu nhỏ để giữ chi phí thấp + Đang cập nhật… + Cập nhật Phối hàng ngày + + + Được tuyển chọn hoàn hảo + Phối hàng ngày + Hành trình âm nhạc của bạn đã sẵn sàng + Trình tạo playlist AI + Mô tả tâm trạng, cảm xúc hoặc hoạt động và để AI tuyển chọn playlist hoàn hảo từ thư viện của bạn. + Kích thước playlist + Tối thiểu bài hát + Tối đa bài hát + ví dụ: Tâm trạng buổi tối thư giãn, năng lượng tập luyện sôi động… + Nhấn để thử lại + Hành trình âm nhạc đã được tổng hợp! + Sẵn sàng phát + Đang tạo… + Tạo playlist + + + Phát gần đây + + + Phát gần đây + Phát mới nhất + Không có phát nào gần đây trong %1$s + Thay đổi phạm vi hoặc phát thêm bài hát để điền vào dòng thời gian này. + Phát gần đây + Hôm nay + Hôm qua + + + Thống kê nghe + Tổng số lần phát + Trung bình/ngày + Bài hát hàng đầu + %1$s • %2$d lần phát + + + Thống kê nghe + Làm mới thống kê nghe + Hôm nay + Tuần đến nay + Tháng đến nay + Năm đến nay + Tất cả thời gian + Nghe + Lần phát + Dòng thời gian nghe + Thời gian nghe + Tổng thời gian nghe được ghi lại trong phạm vi đã chọn. + Số lần phát + Bạn hoàn thành bao nhiêu phiên mỗi phân khúc. + Phiên trung bình + Thời lượng nghe trung bình cho mỗi phân khúc. + Chia thành các khoảng 4 giờ để làm nổi bật nhịp điệu hàng ngày của bạn. + Các thanh hàng ngày giúp so sánh thói quen từ tuần này sang tuần khác dễ dàng hơn. + Các thanh hàng tuần cho thấy xu hướng của tháng. + Các thanh hàng tháng cho thấy tính thời vụ trong suốt năm. + Các thanh hàng năm tóm tắt toàn bộ lịch sử của bạn. + Chưa có dữ liệu nghe + Nhấn phát để bắt đầu xây dựng dòng thời gian nghe của bạn + Nhịp điệu hàng ngày + Nhịp điệu hàng tuần + Nhịp điệu hàng tháng + Năm trong cái nhìn + Tiến triển qua thời gian + Nhóm theo phân khúc 4 giờ + Nhóm theo ngày trong tuần + Nhóm theo tuần trong tháng + Nhóm theo tháng + Nhóm theo năm + Phân khúc đỉnh + %1$d lần phát + + Danh mục hàng đầu + So sánh cách bạn nghe qua thể loại, nghệ sĩ, album và bài hát. + Thể loại + Nghệ sĩ + Album + Bài hát + Nghe theo thể loại + Nghe theo nghệ sĩ + Nghe theo album + Nghe theo bài hát + %1$d lần phát • %2$d nghệ sĩ + %1$d lần phát • %2$d bản nhạc + Chưa có dữ liệu danh mục + Nhấn phát để hiển thị điểm nổi bật về việc nghe của bạn + Thói quen nghe + Chưa có thói quen + Chúng tôi sẽ hiển thị thói quen nghe của bạn khi hiểu bạn rõ hơn. + Tổng số phiên + Phiên trung bình + Phiên dài nhất + Phiên/ngày + Ngày hoạt động nhiều nhất + Chưa phát + Khoảng thời gian đỉnh + Nghệ sĩ hàng đầu + Không có nghệ sĩ hàng đầu + Tiếp tục nghe và nghệ sĩ yêu thích của bạn sẽ xuất hiện ở đây. + \? + %1$d. %2$s + Album hàng đầu + Không có album hàng đầu + Các album bạn thường xuyên nghe lại sẽ xuất hiện ở đây. + %1$d. %2$s + Mật độ bản nhạc + Thời gian nghe của bạn được phân bổ như thế nào qua các bản nhạc hàng đầu. + Chưa có dữ liệu mật độ + Phát thêm bản nhạc để xem mức độ tập trung khi nghe của bạn. + Top 1 + Top 2-3 + Khác + %1$d%% + Mật độ nghe + Top 3 bản nhạc chiếm %1$d%% thời gian nghe của bạn. + Lần phát/bản nhạc trung bình + Bản nhạc duy nhất + Tỷ lệ Top 3 + Bản nhạc trong phạm vi này + Bản nhạc được phát nhiều nhất cho phạm vi thời gian đã chọn. + Không có bản nhạc hàng đầu + Nghe các bản yêu thích của bạn để xem chúng được hiển thị ở đây. + Thu gọn bản nhạc + Hiện tất cả bản nhạc + + + %1$d h %2$02d phút + %1$d phút + %1$d h %2$02d p + %1$d h + %1$d p + %1$d giây + %1$dh %2$02dm + %1$dh + %1$dm + %1$ds + Không bao giờ + Vừa xong + 1 ngày trước + %1$d ngày trước + 1 giờ trước + %1$d giờ trước + 1 phút trước + %1$d phút trước + %1$d bài hát + %1$d bài hát + Tuần %1$d + diff --git a/app/src/main/res/values-vi/strings_library.xml b/app/src/main/res/values-vi/strings_library.xml new file mode 100644 index 0000000000..989484fb5c --- /dev/null +++ b/app/src/main/res/values-vi/strings_library.xml @@ -0,0 +1,566 @@ + + + + Thư viện + Các tab thư viện + Chuyển trực tiếp đến bất kỳ tab nào hoặc sắp xếp lại chúng. + Sắp xếp lại tab + + + Bài hát + Album + Nghệ sĩ + Danh sách phát + Thư mục + Đã thích + + + Đã tạo danh sách phát thành công + Vui lòng đặt API key của nhà cung cấp AI trước + Vui lòng đặt Gemini API key trước + Đã thêm vào hàng chờ + Sẽ phát tiếp theo + + + Chuyển sang đồng hồ + Cài đặt + Chỉnh sửa + Sắp xếp lại tab + Mở rộng menu + + + Bạn có thể chọn tối đa %1$d album + Thư mục + Thư mục + + + Sắp xếp theo + Hiển thị + Chế độ xem danh sách phát + Lưới + Danh sách + Bộ nhớ trong + Thẻ SD + Thẻ SD hiện không khả dụng. + Đám mây + Kênh Telegram Cloud + Hiển thị chủ đề + Kênh + Chủ đề + Cả hai + Đám mây + Chỉ đám mây + + + Đang tạo metadata bằng AI… + + + Lỗi khi tải bài hát + Lỗi khi tải album + Lỗi khi tải nghệ sĩ + Thử lại + + + Không tìm thấy bài hát nào trong thư viện. + Hãy thử quét lại thư viện trong cài đặt nếu bạn có nhạc trên thiết bị. + Không tìm thấy bài hát + + + Mới + Tạo danh sách phát mới + Nhập danh sách phát M3U + Tìm bài hát hiện tại + Tất cả bài hát + ĐÁM MÂY + CỤC BỘ + Tùy chọn sắp xếp + + + Tất cả + Bỏ chọn + Thêm tùy chọn + + + Đang quét tệp nhạc… + Đang xử lý tệp… + %1$d trên %2$d tệp + Đang đồng bộ thư viện… + Đồng bộ hoàn tất + Đang chờ… + Đang đồng bộ thư viện… + Đang xóa bộ nhớ đệm ảnh bìa… + Đang đồng bộ nguồn đám mây… + Đang quét lời bài hát… + + + Chưa có bài hát nào + Thêm nhạc vào thiết bị hoặc đồng bộ nguồn đám mây để bắt đầu nghe. + Không tìm thấy bài hát cục bộ + Hãy thử bộ lọc nguồn khác hoặc quét lại thư viện trên thiết bị. + Không tìm thấy bài hát trên đám mây + Đồng bộ bài hát từ Telegram hoặc NetEase, hoặc chuyển sang nguồn cục bộ. + Chưa có album nào + Album sẽ xuất hiện ở đây khi thư viện đã nhóm các bài hát. + Không tìm thấy album cục bộ + Cần có bài hát cục bộ để tạo nhóm album cục bộ. + Không tìm thấy album đám mây + Bài hát trên đám mây có dữ liệu album sẽ xuất hiện ở đây sau khi đồng bộ. + Chưa có nghệ sĩ nào + Nghệ sĩ sẽ được hiển thị sau khi bài hát được lập chỉ mục từ bất kỳ nguồn nào. + Không tìm thấy nghệ sĩ cục bộ + Hiện chưa có metadata nghệ sĩ cho các bài hát cục bộ. + Không tìm thấy nghệ sĩ đám mây + Mục nghệ sĩ đám mây xuất hiện khi các bài hát từ xa được đồng bộ. + Chưa có bài hát yêu thích nào + Nhấn vào biểu tượng trái tim khi đang phát bài hát để lưu vào đây. + Không có bài hát cục bộ yêu thích + Chuyển bộ lọc nguồn hoặc thích bài hát từ thiết bị. + Không có bài hát đám mây yêu thích + Thích bài hát từ Telegram hoặc NetEase để xem chúng trong chế độ này. + Không tìm thấy thư mục nào + Các thư mục bộ nhớ trong có nhạc sẽ xuất hiện ở đây. + Chưa có danh sách phát nào + Tạo danh sách phát đầu tiên để tổ chức thư viện. + + + Chỉnh sửa metadata bài hát + Phát + Phát bài hát + Phát tất cả + Phát tất cả + Thêm vào yêu thích + Thêm tất cả vào yêu thích + Xóa khỏi yêu thích + Xóa tất cả khỏi yêu thích + Chia sẻ tệp bài hát qua + Chia sẻ tệp bài hát + Chia sẻ tất cả dưới dạng ZIP + Không thể chia sẻ bài hát: %1$s + Thêm vào hàng chờ + Thêm vào hàng chờ + Tiếp theo + Phát tiếp theo trong hàng chờ + Thêm vào danh sách phát + Xóa + Xóa tất cả + Đang kiểm tra đồng hồ + Đang chuyển %1$d%% + Đang chuyển sang đồng hồ + Đang trong quá trình chuyển + Gửi đến đồng hồ + Đồng hồ không khả dụng + Gửi bài hát đến đồng hồ + Đồng hồ không khả dụng + Đặt làm + Đặt làm âm thanh + Chọn cách sử dụng bài hát này làm âm thanh hệ thống + Sử dụng bài hát này làm + Chọn nơi PixelPlayer sẽ cài đặt âm thanh này. + Nhạc chuông điện thoại + Cuộc gọi đến + Âm thanh thông báo + Tin nhắn và cảnh báo ứng dụng + Âm thanh báo thức + Báo thức đồng hồ + Xác nhận thay đổi âm thanh + Đặt \"%1$s\" làm %2$s? + Đặt âm thanh + Đã đặt \"%1$s\" làm %2$s + nhạc chuông + âm thanh thông báo + âm thanh báo thức + Bật \"Sửa đổi cài đặt hệ thống\", sau đó quay lại PixelPlayer để hoàn tất tự động. + Chưa bật \"Sửa đổi cài đặt hệ thống\". + Đã đặt \"%1$s\" làm nhạc chuông + Chỉ bài hát cục bộ mới có thể dùng làm nhạc chuông. + Không thể chuẩn bị tệp âm thanh này làm nhạc chuông. + Không thể đặt nhạc chuông: %1$s + Tùy chọn + TÙY CHỌN + Thông tin + THÔNG TIN + Thời lượng + Thể loại + Album + Nghệ sĩ + Thông tin bài hát + Nhà cung cấp + Tệp + %1$d BÀI HÁT + đã chọn + %1$d DANH SÁCH PHÁT + %1$d ALBUM + đã chọn + Giới hạn: %1$d album mỗi lần chọn. + Hàng chờ + phát sẽ theo thứ tự bạn chọn. + %1$d THỂ LOẠI + đã chọn + Thực hiện thao tác hàng loạt trên tất cả bài hát thuộc các thể loại này. + + + Thứ tự mặc định + Tiêu đề (A-Z) + Tiêu đề (Z-A) + Nghệ sĩ + Nghệ sĩ (Z-A) + Album + Album (Z-A) + Ngày thêm + Ngày thêm (Cũ nhất trước) + Thời lượng + Thời lượng (Ngắn nhất trước) + Năm phát hành + Năm phát hành (Cũ nhất trước) + Ít bài hát nhất + Nhiều bài hát nhất + Tên (A-Z) + Tên (Z-A) + Số lượng bài hát (Nhiều nhất) + Số lượng bài hát (Ít nhất) + Ngày tạo + Ngày tạo (Cũ nhất trước) + Ngày thích + Ngày thích (Cũ nhất trước) + Ít thư mục con nhất + Nhiều thư mục con nhất + + + Tiêu đề + Nghệ sĩ + Album + Ngày thêm + Thời lượng + Năm phát hành + Số lượng bài hát + Tên + Số lượng bài hát + Ngày tạo + Ngày thích + Số lượng thư mục con + + + Nguồn + Thứ tự + Giảm dần + Tăng dần + Thứ tự gốc + Nhấn để chuyển sang tăng dần + Nhấn để chuyển sang giảm dần + Sắp xếp này giữ nguyên thứ tự gốc + Đang bật + + + Sắp xếp lại tab thư viện + Đặt lại thứ tự + Đặt lại thứ tự tab về mặc định? + Đang sắp xếp lại tab… + Thanh kéo + + + Chọn nghệ sĩ + 1 nghệ sĩ + %1$d nghệ sĩ + Nghệ sĩ chính + Trang nghệ sĩ + + + Hủy chuyển + %1$s / %2$s + Hiển thị tiến trình chuyển nhạc từ điện thoại sang đồng hồ + Chuyển sang đồng hồ + Đang gửi đến đồng hồ + Đã hủy + Đã hủy chuyển + Chuyển hoàn tất + Hoàn tất + Thất bại + Chuyển thất bại + Nhiều lượt chuyển đang hoạt động + %1$s • %2$s + Đang chuẩn bị + Đang chuẩn bị chuyển sang đồng hồ + Đang chuẩn bị chuyển… + Đang gửi %1$d bài hát đến đồng hồ + Đang gửi đến đồng hồ + Đang bắt đầu chuyển… + Đang bắt đầu + Đang chuyển + %1$d lượt chuyển + + + Chỉnh sửa bài hát + Hiển thị thông tin + Đang chỉnh sửa metadata bài hát + Việc chỉnh sửa metadata bài hát có thể ảnh hưởng đến cách hiển thị và tổ chức trong thư viện. Các thay đổi là vĩnh viễn và có thể không thể hoàn tác. + Đã hiểu + Thông tin + Ảnh bìa + Chọn hình ảnh vuông và điều chỉnh để ảnh bìa hiển thị tốt trên toàn ứng dụng. + Đổi ảnh bìa + Xóa ảnh bìa + Tiêu đề + Nghệ sĩ + Album + Nghệ sĩ album + Thể loại + Nhà soạn nhạc + Số thứ tự bài hát + Số thứ tự đĩa + ReplayGain bài hát (dB) + ReplayGain album (dB) + -6.50 + -8.20 + Xem trước ảnh bìa mới + Ảnh bìa bài hát hiện tại + Điều chỉnh ảnh bìa + Sử dụng thao tác cuộn và kéo để tìm khung hình hoàn hảo. + Áp dụng ảnh bìa + Không thể tải hình ảnh đã chọn + Tìm lời bài hát trên lrclib.net + + + Chỉnh sửa %d bài hát + Chỉ các trường đã sửa sẽ được cập nhật. Để trống trường để giữ nguyên giá trị hiện tại. + (Giá trị hỗn hợp) + (Tùy chọn - để trống để bỏ qua) + Đã cập nhật %d bài hát thành công + Đã cập nhật %1$d trên %2$d bài hát. Một số tệp không thể chỉnh sửa. + Cập nhật bài hát thất bại + Ảnh bìa hàng loạt + Thao tác này sẽ thay thế ảnh bìa cho tất cả %d bài hát đã chọn + Đặt ảnh bìa cho tất cả + Xóa tất cả ảnh bìa + (Nhiều ảnh bìa khác nhau) + + + Đã đóng danh sách phát + + + Tạo danh sách phát + Chọn luồng tạo. + Thủ công + Tự thiết kế hình ảnh, biểu tượng, hình dạng và chọn bài hát. + Với AI + Tạo danh sách phát được tuyển chọn với các điều khiển nâng cao. + Cần có Gemini API key đã cấu hình trong cài đặt. + Thiết lập API key + + + Phòng thí nghiệm danh sách phát AI + Đặt lại + Đang tạo… + Tạo + Mục đích + Tên danh sách phát (tùy chọn) + Danh sách phát này nên có cảm giác như thế nào? + Ví dụ: lái xe lúc hoàng hôn với âm thanh synth ấm áp + Hướng đi + Tâm trạng + Hoạt động + Thời đại + Công cụ tuyển chọn + Năng lượng + Điều chỉnh cường độ và tiết tấu của bài hát. 1 = tĩnh lặng/chậm, 5 = năng lượng cao/nhanh. + Khám phá + Điều chỉnh mức độ quen thuộc của các bài hát được chọn. 1 = bài yêu thích phát nhiều nhất, 5 = bài hiếm khi phát. + Tối thiểu bài hát + Tối đa bài hát + Bộ lọc + Ưu tiên thể loại (tùy chọn) + ví dụ: synthwave, indie pop + Tránh thể loại (tùy chọn) + ví dụ: metal, hard trap + Ngôn ngữ ưu tiên (tùy chọn) + ví dụ: English, Spanish, instrumental + Ưu tiên bài yêu thích + Tránh lời bài hát thô tục + Xem trước prompt + Prompt cuối cùng sẽ xuất hiện ở đây khi bạn thêm tùy chọn. + Tuyển chọn chính xác + Xác định tâm trạng, hoạt động, ràng buộc và độ sâu. + AI chỉ sử dụng bài hát từ thư viện cục bộ của bạn. + Thêm ít nhất một hướng dẫn cho AI. + Đặt khoảng bài hát hợp lệ. + %1$d/5 + Tùy chỉnh… + Nhập giá trị tùy chỉnh + Nhập giá trị tùy chỉnh + + + Bất kỳ thời đại nào + Yêu cầu cốt lõi: %1$s. + Tâm trạng mục tiêu: %1$s. + Bối cảnh hoạt động: %1$s. + Tập trung thời đại: %1$s. + Ưu tiên thể loại: %1$s. + Tránh thể loại: %1$s. + Ngôn ngữ ưu tiên: %1$s. + Mục tiêu mức năng lượng: %1$d/5. + Mục tiêu khám phá: %1$d/5 trong đó 1 là quen thuộc và 5 là bài hiếm. + Ưu tiên bài hát gần với sở thích của người nghe khi có thể. + Tránh lời bài hát thô tục mỗi khi có phương án thay thế. + Giữ chuyển tiếp mượt mà và tránh lặp lại nhóm nghệ sĩ. + + Điềm tĩnh + Tràn đầy năng lượng + Vui vẻ + Tối tăm + Lãng mạn + U sầu + + + Tập thể dục + Tập trung + Đi đường dài + Party + Học tập + Đêm khuya + + + @string/playlist_creation_ai_era_any + 70s + 80s + 90s + 2000s + 2010s + 2020s + + + + Chưa tạo danh sách phát nào. + Nhấn nút \'Danh sách phát mới\' để bắt đầu. + Danh sách phát mới + Tên danh sách phát + Danh sách phát của tôi + + + Thêm %1$d bài hát vào… + Chọn danh sách phát + Tìm danh sách phát… + Đã thêm bài hát vào danh sách phát + Đã tạo danh sách phát và thêm bài hát + Bộ nhớ trong + + + Thêm bài hát + Thêm các bài hát đã chọn + Thêm + Tìm kiếm hoặc lọc bài hát… + Đã thích + Tải bài hát thất bại + Tải thêm + + + Gộp danh sách phát + Nhập tên cho danh sách phát đã gộp: + Danh sách phát đã gộp + Thao tác này sẽ gộp %1$d danh sách phát đã chọn thành một. + + + Không tìm thấy bài hát hợp lệ để phát + Không tìm thấy bài hát trong danh sách hiện tại + Không thể tìm bài hát + Không tìm thấy bài hát trong thư viện + Đã dừng phát: %1$s kết thúc (Hết bài). + Bài hát + Không có bài hát để trộn. + Album đã chọn + Không tìm thấy bài hát có thể phát trong album đã chọn + Không tìm thấy bài hát có thể phát trong thể loại đã chọn + Chỉ %1$d album đầu tiên được thêm vào hàng chờ + %1$d album đã thêm vào hàng chờ (%2$d bài hát) + Không thể thêm album đã chọn vào hàng chờ + Tất cả bài hát đã có trong yêu thích + Không có bài hát nào trong yêu thích + Đang tạo tệp ZIP… + Chia sẻ thất bại: %1$s + + Đã thêm %d bài hát vào hàng chờ + Đã thêm %d bài hát vào hàng chờ + + + %d bài hát sẽ phát tiếp theo + %d bài hát sẽ phát tiếp theo + + + Đã thêm %d bài hát vào yêu thích + Đã thêm %d bài hát vào yêu thích + + + Đã xóa %d bài hát khỏi yêu thích + Đã xóa %d bài hát khỏi yêu thích + + + + Không có danh sách phát để chia sẻ + Chia sẻ danh sách phát + Chia sẻ thất bại: %1$s + Không có danh sách phát để xuất + Xuất thất bại: %1$s + Nhạc/Xuất PixelPlayer + Vui lòng cấu hình Gemini API key trong Cài đặt. + Đã khôi phục danh sách phát + + Đang chia sẻ %d danh sách phát + Đang chia sẻ %d danh sách phát + + + Đã xuất %1$d danh sách phát tới %2$s + Đã xuất %1$d danh sách phát tới %2$s + + + + ID album không hợp lệ + Không tìm thấy ID album + Lỗi khi tải dữ liệu album: %s + Không tìm thấy album + + + ID nghệ sĩ không hợp lệ + Không tìm thấy ID nghệ sĩ + Lỗi khi tải dữ liệu nghệ sĩ: %s + Không tìm thấy nghệ sĩ + + + Không thể xóa bài hát đang phát + Đã xóa %1$d tệp (%2$d bị bỏ qua - đang phát) + Đã xóa %1$d trong %2$d tệp + Xóa tệp thất bại + Đã xóa tệp + Không thể xóa tệp hoặc tệp không tìm thấy + Đã hủy xóa + Xóa bài hát? + \"%1$s\" của %2$s\n\nBài hát này sẽ bị xóa vĩnh viễn khỏi thiết bị và không thể khôi phục. + Các bài hát này sẽ bị xóa vĩnh viễn khỏi thiết bị và không thể khôi phục. + + Đã xóa %d tệp + Đã xóa %d tệp + + + Xóa %d bài hát? + Xóa %d bài hát? + + + + Đã cập nhật metadata thành công + Đang cập nhật %1$d bài hát… + Đã cập nhật %1$d bài hát thành công! + Đã cập nhật %1$d bài hát. Thất bại: %2$d + Đã lưu lời bài hát thành công + Lưu lời bài hát thất bại + Không có lời bài hát để lưu + Bị từ chối quyền - không thể chỉnh sửa tệp + Bị từ chối quyền - không thể lưu lời bài hát + Bị từ chối quyền - không thể chỉnh sửa tệp này + + + Vui lòng cấu hình API key hợp lệ cho nhà cung cấp AI đã chọn trong Cài đặt. + Lỗi AI: %s + Nhà cung cấp AI đã chọn từ chối yêu cầu vì tài khoản không có credit hoặc quota khả dụng. + Mô hình AI đã chọn không còn khả dụng. PixelPlayer đã thử chuyển sang mô hình được hỗ trợ tự động. + AI không tìm thấy bài hát nào cho prompt của bạn. + Viết ý tưởng cho Daily Mix của bạn + Đã cập nhật Daily Mix bằng AI + Không thể cập nhật: %s + AI không tìm thấy bài hát cho mix này + diff --git a/app/src/main/res/values-vi/strings_player.xml b/app/src/main/res/values-vi/strings_player.xml new file mode 100644 index 0000000000..769a6c862c --- /dev/null +++ b/app/src/main/res/values-vi/strings_player.xml @@ -0,0 +1,198 @@ + + + + Thu nhỏ trình phát + Đang phát + Phát trực tuyến đám mây + Cast + Bluetooth + Phát cục bộ + Đang kết nối… + Mở hàng đợi + + + Sẵn sàng kết nối + Cho phép PixelPlayer xem các thiết bị ở gần và Wi‑Fi hiện tại của bạn để chúng tôi có thể giữ cho cast, âm thanh Bluetooth và loa luôn đồng bộ. + Thiết bị ở gần + Cần thiết để đọc và điều khiển thiết bị âm thanh Bluetooth đã kết nối của bạn. + Vị trí cho Wi‑Fi + Android yêu cầu Vị trí để chia sẻ mạng Wi‑Fi (SSID) mà bạn đang sử dụng để chúng tôi có thể tìm các thiết bị cast tương thích. + Cho phép truy cập + Chúng tôi chỉ sử dụng các quyền này cho việc kết nối thiết bị — cast, điều khiển loa ở gần và giữ cho âm thanh đồng bộ. + Kết nối thiết bị + Đang quét thiết bị ở gần + Phiên cast + Đang kết nối + Đã kết nối + Điện thoại này + Âm thanh Bluetooth + Phát cục bộ + Đang phát + Tạm dừng + Âm lượng thiết bị + Âm lượng điện thoại + %1$d/%2$d + Mức pin + Mức âm lượng + Ngắt kết nối + Kết nối + Bật Wi-Fi hoặc Bluetooth + Làm mới kết nối + Wi-Fi + Tắt + Bật + Đã kết nối + Bluetooth + Tắt + Bật + Đã kết nối + Thiết bị ở gần + Làm mới thiết bị + Đã kết nối + Đang kết nối + Sẵn sàng kết nối + Sẵn có + Đang kết nối... + Đang tìm thiết bị… + Hãy đảm bảo TV hoặc loa của bạn đang bật và chia sẻ cùng mạng Wi‑Fi. + Điều khiển + Thiết bị + + + Máy chủ media Cast + Đang cast đến thiết bị + Đang cung cấp media cho thiết bị Cast + %1$s: %2$s + Tua tạm thời không khả dụng cho định dạng âm thanh này trên Cast vì nó có thể làm phiên cast bị crash. + + + Hẹn giờ ngủ + Hẹn giờ + %1$d phút + Đã đặt hẹn giờ %1$d phút. + 1 lần + + %d lần + %d lần + + Số lần phát: %1$s + Kết thúc bài hát hiện tại + Phát sẽ dừng ở cuối bài hát. + Bật + Thời gian tùy chỉnh + Hủy hẹn giờ + Kết thúc bài hát + Đã hủy hẹn giờ. + Không thể bật kết thúc bài hát: không có bài hát đang phát. + Hẹn giờ kết thúc bài hát đã tắt: bài hát đã đổi từ %1$s sang %2$s. + Bài trước + Bài hiện tại + Đặt thời lượng tùy chỉnh + + + Tiếp theo + Hàng đợi hiện đang trống. + + 1 bài đã xếp hàng. + %d bài đã xếp hàng. + + Hàng đợi + Hàng đợi trống. + Sắp xếp lại bài hát + Bật/tắt xáo trộn + Bật/tắt lặp lại + Hẹn giờ ngủ + Thêm thao tác + Định vị bài hát hiện tại + Xóa hàng đợi + Xóa hàng đợi + Bạn có chắc muốn xóa tất cả bài hát khỏi hàng đợi ngoại trừ bài hiện tại? + Lưu thành danh sách phát + Hàng đợi %1$s + Hàng đợi hiện tại + Bỏ bài hát + đã xóa + Lưu thành danh sách phát + Bỏ chọn tất cả + Tên danh sách phát + Tìm bài hát để thêm… + Không có bài hát nào khớp \"%1$s\" + + %d bài đã chọn + %d bài đã chọn + + Lưu dưới dạng: %1$s + Nhập tên danh sách phát + Xóa khỏi danh sách phát + Thêm tùy chọn cho %1$s + + + Lời bài hát + Đang tải lời bài hát… + Đồng bộ + Cố định + Tùy chọn lời bài hát + −.5 + −.1 + +.1 + +.5 + 0s + %1$+.1fs + + + Không thể tìm lời bài hát + Không thể tải lời bài hát từ máy chủ + Kết nối đã hết thời gian. Vui lòng kiểm tra kết nối internet của bạn. + Lỗi mạng. Vui lòng kiểm tra kết nối internet của bạn. + Lỗi máy chủ (mã %d). Vui lòng thử lại sau. + + + Lời bài hát đã có sẵn. Bỏ qua tải trực tuyến. + Lời bài hát nhúng đã có sẵn. Bỏ qua tải trực tuyến. + Lời bài hát cục bộ (.lrc) đã có sẵn. Bỏ qua tải trực tuyến. + + + Lưu lời bài hát + Dịch qua AI + Lời bài hát này đã có bản dịch + Lời bài hát này đã bằng ngôn ngữ này + API chưa được cấu hình + Dịch lời bài hát thành công! + Đang dịch lời bài hát... + Đặt lại lời bài hát đã nhập + Đặt lại lời bài hát? + Bạn có chắc muốn đặt lại lời bài hát cho bài hát này? + Giao diện + Căn lề + Căn lời bài hát bên trái + Căn lời bài hát giữa + Căn lời bài hát bên phải + Điều khiển + Điều chỉnh đồng bộ + Ẩn điều khiển đồng bộ + Hiện phiên âm + Hiện bản dịch + Tắt chế độ toàn màn hình (một lần) + Giữ màn hình sáng + + + Lưu lời bài hát + Chọn phiên bản để lưu: + Đồng bộ (có đánh dấu thời gian) + Thô (chỉ văn bản) + + + Bạn có muốn tìm lời bài hát trực tuyến không? + Hiện tùy chọn lời bài hát + Luôn mở bộ chọn thay vì tự động áp dụng kết quả phù hợp đầu tiên + Đang tìm lời bài hát… + Không tìm thấy lời bài hát + Chúng tôi không thể tự động tìm lời bài hát. Bạn có thể chỉnh sửa tên bài hát hoặc nghệ sĩ và thử tìm kiếm thủ công. + Tên bài hát + Nghệ sĩ (tùy chọn) + Tìm thấy %d kết quả phù hợp + ĐỒNG BỘ + %1$s • %2$s + Lời bài hát được cung cấp bởi + https://lrclib.net/ + diff --git a/app/src/main/res/values-vi/strings_screens.xml b/app/src/main/res/values-vi/strings_screens.xml new file mode 100644 index 0000000000..357e18951a --- /dev/null +++ b/app/src/main/res/values-vi/strings_screens.xml @@ -0,0 +1,244 @@ + + + + Lỗi: Thiếu ID thể loại + + + Bắt đầu! + Bước %1$d/%2$d + Vui lòng cấp quyền cần thiết trước. + Vui lòng cấp tất cả quyền cần thiết. + Chào mừng đến + β + Beta + Hãy thiết lập mọi thứ cho bạn. + Quyền đa phương tiện + PixelPlayer cần truy cập tệp âm thanh để xây dựng thư viện nhạc của bạn. + Đã cấp quyền + Cấp quyền đa phương tiện + Thông báo + Bật thông báo để điều khiển nhạc từ màn hình khóa và bảng thông báo. + Bật thông báo + Bạn có bản sao lưu không? + Nếu bạn đã có bản sao lưu PixelPlayer, hãy khôi phục ngay và bỏ qua hầu hết thiết lập còn lại trên thiết bị này. + Nhập bản sao lưu + Kiểm tra bản sao lưu + Kiểm tra gói sao lưu… + Khôi phục bản sao lưu + Bỏ qua / Chưa + Khôi phục bản sao lưu + Xem xét những gì bạn muốn nhập trước khi hoàn tất thiết lập. + Đã chọn %1$d/%2$d mô-đun + Đã tạo %1$s + Bản sao lưu từ %1$s + Phiên bản không xác định + Khôi phục đã chọn + Đang khôi phục + Thư mục bị loại trừ + Mặc định tất cả thư mục đều được quét. Chọn các vị trí bạn muốn bỏ qua khi xây dựng thư viện. + Chọn thư mục bỏ qua + Hãy cấp quyền lưu trữ trước + Giao diện ứng dụng + Chọn giao diện bạn muốn trước khi bắt đầu khám phá thư viện. + Tối + Giao diện Material 3 tối mặc định cho PixelPlayer. + Sáng + Giao diện Material 3 sáng hơn trên toàn ứng dụng. + Theo hệ thống + Khớp với cài đặt giao diện hiện tại của điện thoại. + Đề xuất + Bạn có thể thay đổi sau trong Cài đặt > Giao diện > Giao diện ứng dụng. + Bố cục thư viện + Chọn cách điều hướng thư viện ưa thích. + Bài hát + Chế độ thu gọn + Sử dụng thanh điều hướng pill tối giản + Sử dụng hàng tab tiêu chuẩn + BÀI HÁT + ALBUM + NGHỆ SĨ + Bạn có thể thay đổi sau trong Cài đặt > Giao diện > Điều hướng thư viện. + Điều hướng ứng dụng + Chọn kiểu thanh điều hướng dưới cùng. + Kiểu mặc định + Pill nổi với góc tròn + Thanh tiêu chuẩn đầy đủ chiều rộng + Tùy chỉnh bán kính góc + Bạn có thể thay đổi sau trong Cài đặt > Giao diện > Kiểu thanh điều hướng. + Báo thức & Nhắc nhở + Tùy chọn, nhưng được khuyến nghị nếu bạn sử dụng Hẹn giờ ngủ và muốn PixelPlayer dừng phát đúng giờ. + Cấp quyền + Tối ưu hóa pin + Một số thiết bị Android killing ứng dụng nền mạnh. Tắt tối ưu hóa pin cho PixelPlayer để ngăn chặn sự cố phát ngắt quãng bất ngờ. + Tắt tối ưu hóa + Sẵn sàng! + Bạn đã sẵn sàng thưởng thức nhạc. + + + Tìm kiếm… + Tìm kiếm + Xóa tìm kiếm + Tìm kiếm gần đây + Xóa tất cả + Lịch sử + Xóa mục lịch sử tìm kiếm + Không có kết quả + Không có kết quả cho \"%1$s\" + Không tìm thấy gì + Thử từ khóa khác hoặc kiểm tra bộ lọc. + Không tìm thấy kết quả. + Duyệt theo thể loại + Không có thể loại nào. + + + Phát %1$s + Thu gọn %1$s + Mở rộng %1$s + Chỉnh sửa ảnh nghệ sĩ + Đổi ảnh + Đặt lại mặc định + Phát ngẫu nhiên nghệ sĩ + + + Đĩa %d + Ảnh bìa của %1$s + %1$s · %2$s + + + Không tìm thấy playlist. + Playlist này trống. + Nhấn \'Thêm bài hát\' để bắt đầu. + Thư mục này không chứa bài hát. + Sắp xếp bài hát + Tùy chọn khác + Tùy chọn playlist + Chỉnh sửa playlist + Xóa playlist + Xóa playlist? + Bạn có chắc muốn xóa playlist này? + Đặt chuyển tiếp mặc định + Xuất playlist + %1$s • %2$s + Phát ngay + Thêm + Thêm bài hát + Xóa + Xóa bài hát + Sắp xếp lại + Sắp xếp lại bài hát + + + Chuyển tiếp toàn cục + Quy tắc playlist + Cấu hình này áp dụng cho tất cả nguồn phát trừ khi bị ghi đè. + Cấu hình hành vi mặc định cho playlist cụ thể này. + Trạng thái hoạt động + Mặc định toàn cục + Mặc định playlist + Theo toàn cục + Ghi đè tùy chỉnh + Ghi đè tùy chỉnh + Bật để đặt quy tắc cụ thể cho playlist này. + Sử dụng mặc định toàn cục + Đã lưu thay đổi thành công + Kiểu chuyển tiếp + Cách bản nhạc hòa trộn + Không + Crossfade + Thời lượng chuyển tiếp + Tổng chồng chéo %1$ds + Đặt lại chuyển tiếp + Bài hát hiện tại + Bài hát tiếp theo + Các bản nhạc sẽ chồng chéo trong %1$ds + Đường cong âm lượng + Điều chỉnh tông âm thanh + Fade Out + Fade In + + + Playlist thông minh mới + Playlist mới + Thêm bài hát + Quay lại hoặc Hủy + Tiếp theo + Tạo + Chỉnh sửa playlist + Collage tự động + Thêm ảnh + Chọn ảnh + Đổi + Xóa + Tên playlist + Playlist tuyệt vời của tôi + Chỉnh sửa bìa + Điều chỉnh ảnh bìa + Sử dụng thao tác vuốt và kéo để tìm khung hình hoàn hảo + Thủ công + Thông minh + Tạo với AI + Quy tắc thông minh + Mặc định + Ảnh + Biểu tượng + Màu nền + Ký hiệu biểu tượng + Kiểu hình dạng + Tham số hình dạng + Bán kính góc + Độ mượt + Cạnh + Đường cong + Xoay + Tỷ lệ + Phát nhiều nhất + Các bản nhạc bạn phát nhiều nhất. + Phát gần đây + Bài hát bạn nghe gần đây nhất. + Yêu thích bị lãng quên + Bản nhạc yêu thích bạn đã lâu không nghe. + Kho báu mới + Bản nhạc mới thêm với số lần phát thấp. + + + Điền nhanh thể loại + Sắp xếp & Phát + Phát ngẫu nhiên + Sắp xếp theo + Nghệ sĩ + Album + Tiêu đề + Nghệ sĩ chung + %1$s Ngẫu nhiên + + + Chọn bài hát + Chọn thể loại + Tìm bài hát + Thể loại mới + Thêm tùy chỉnh + Thêm thể loại tùy chỉnh + Tên thể loại + Chọn biểu tượng + Thể loại: %1$s + Chọn thể loại + Điền nhanh + + + DJ Space + Đang tải… + Bộ %1$d + Tải bài hát + Chưa tải bài hát + + Phân tách stem chưa khả dụng. + Âm lượng + Tốc độ + Crossfader + Bộ 1 + Bộ 2 + Chọn bài hát + Phát/Tạm dừng + Ảnh bìa bài hát + x%1$.2f + diff --git a/app/src/main/res/values-vi/strings_settings.xml b/app/src/main/res/values-vi/strings_settings.xml new file mode 100644 index 0000000000..7082109828 --- /dev/null +++ b/app/src/main/res/values-vi/strings_settings.xml @@ -0,0 +1,665 @@ + + + + Quản lý nhạc + Quản lý thư mục, làm mới thư viện, tùy chọn phân tích + Giao diện + Chủ đề, bố cục và kiểu hiển thị + Lời bài hát + Ưu tiên nguồn, lời bài hát đắm chìm và quản lý nhập + Phát lại + Hành vi âm thanh, crossfade và phát nền + Hành vi + Cử chỉ, rung cảm ứng và hành vi điều hướng + Tích hợp AI (β) + Nhà cung cấp AI, API key, chọn mô hình và nhật ký hoạt động + Tham số tạo + Nhiệt độ, lấy mẫu, prompt và cấu hình dữ liệu bài hát + Sao lưu & Khôi phục + Xuất và khôi phục dữ liệu cá nhân của bạn + Tùy chọn nhà phát triển + Tính năng thử nghiệm và gỡ lỗi + Bộ equalizer + Điều chỉnh tần số âm thanh và preset + Khả năng thiết bị + Thông số âm thanh, codec và thông tin bộ giải mã + Tài khoản + Quản lý Telegram, Google Drive, NetEase và nhiều dịch vụ khác + Giới thiệu + Thông tin ứng dụng, phiên bản và credits + + + Bật + Tắt + Đã bật + Đã tắt + Mở + Chọn tất cả + Xóa lựa chọn + Đóng thông báo + + + Cấu trúc thư viện + Thư mục loại trừ + Các thư mục ở đây sẽ bị bỏ qua khi quét thư viện của bạn. + Nghệ sĩ + Tùy chọn phân tích và tổ chức đa nghệ sĩ. + Lọc + Thời lượng bài hát tối thiểu + Số bài hát tối thiểu mỗi album + Giới hạn bộ nhớ đệm ảnh album + Đồng bộ và quét + Làm mới thư viện + Quét toàn bộ thư viện để tìm file mới và đã sửa đổi. + Quét lại toàn bộ + Đang quét lại toàn bộ + Đã bắt đầu quét lại toàn bộ… + Đồng bộ thư viện hoàn tất + Xây dựng lại cơ sở dữ liệu + Xây dựng lại cơ sở dữ liệu? + Điều này sẽ xây dựng lại toàn bộ thư viện nhạc từ đầu. Tất cả lời bài hát đã nhập, yêu thích và metadata tùy chỉnh sẽ bị mất. Hành động này không thể hoàn tác. + Xây dựng lại + Đang xây dựng lại cơ sở dữ liệu + Đang xây dựng lại cơ sở dữ liệu… + Tự động quét file .lrc + Tự động quét và gán file .lrc trong cùng thư mục khi đồng bộ thư viện. + Quản lý lời bài hát + Ưu tiên nguồn lời bài hát + Chọn nguồn nào thử trước khi lấy lời bài hát. + Embedded trước + Trực tuyến trước + Local (.lrc) trước + Đặt lại lời bài hát đã nhập + Xóa tất cả lời bài hát đã nhập khỏi cơ sở dữ liệu. + Đặt lại lời bài hát đã nhập? + Hành động này không thể hoàn tác. + + + Làm mới + Mọi thứ được phép theo mặc định. Nhấn vào thư mục để đánh dấu loại trừ khỏi quét. + Không có thư mục con ở đây + Đi lên + Về thư mục gốc + + + Cần quét lại + Cài đặt nghệ sĩ đã thay đổi. Quét lại thư viện để áp dụng. + Quét lại + Đang quét… + Phân tích đa nghệ sĩ + Dấu phân cách ký tự + Hiện tại: %1$s + Dấu phân cách từ + Không có + Hiện tại: %1$s + Cấu hình + Trích xuất nghệ sĩ từ tiêu đề + Phát hiện feat., ft., with trong tiêu đề bài hát + Tổ chức thư viện + Nhóm theo nghệ sĩ album + Hiển thị album hợp tác dưới nghệ sĩ chính + Về phân tích đa nghệ sĩ + + PixelPlayer tách tag nghệ sĩ sử dụng dấu phân cách ký tự (/, ;, &) và dấu phân cách từ (feat., ft., vs., x). Dấu phân cách từ được khớp không phân biệt hoa thường. + \"Trích xuất nghệ sĩ từ tiêu đề\" phát hiện các mẫu như (feat. Artist) trong tiêu đề bài hát. + Dấu gạch chéo ngược (\\) có thể được sử dụng để escape dấu phân cách ký tự. + + Ví dụ + \u2192 + \u266A + \"Artist1/Artist2\" + Artist1, Artist2 + \"Drake feat. Rihanna\" + Drake, Rihanna + \"Marshmello x Bastille\" + Marshmello, Bastille + \"Song (ft. B)\" by A + A, B + \"AC\\DC\" + AC/DC (escaped) + + + Dấu phân cách + Dấu phân cách hiện tại + Nhấn vào dấu phân cách để xóa. Cần ít nhất một dấu phân cách. + Thêm dấu phân cách mới + ví dụ: / hoặc ; + Thêm dấu phân cách + Dấu phân cách mặc định + Đặt lại dấu phân cách? + Điều này sẽ xóa tất cả dấu phân cách tùy chỉnh của bạn và khôi phục mặc định. Hành động này không thể hoàn tác. + Dấu phân cách đã đặt lại mặc định + Cần ít nhất một dấu phân cách + Đã thêm dấu phân cách + Dấu phân cách đã tồn tại hoặc không hợp lệ + Dấu cách + + + Dấu phân cách từ + Dấu phân cách từ hiện tại + Các từ khóa này tách tên nghệ sĩ khi được bao quanh bởi dấu cách. Khớp không phân biệt hoa thường. Nhấn để xóa. + Chưa cấu hình dấu phân cách từ + Thêm dấu phân cách từ mới + ví dụ: feat. hoặc ft. + Thêm dấu phân cách từ + Cách dấu phân cách từ hoạt động + Dấu phân cách từ được khớp không phân biệt hoa thường với dấu cách xung quanh.\n\nDấu phân cách một ký tự (như \"x\") cần dấu cách ở cả hai bên để tránh khớp sai.\n\nVí dụ:\n \"Drake feat. Rihanna\" -> Drake, Rihanna\n \"Marshmello x Bastille\" -> Marshmello, Bastille\n \"A vs. B\" -> A, B + Đặt lại dấu phân cách từ? + Điều này sẽ xóa tất cả dấu phân cách từ tùy chỉnh của bạn và khôi phục từ khóa mặc định. Hành động này không thể hoàn tác. + Đã thêm dấu phân cách từ + Đã tồn tại hoặc không hợp lệ + Dấu phân cách từ đã đặt lại mặc định + + + Chuẩn bị đồng bộ + Đọc MediaStore + Xử lý bài hát + Lưu vào cơ sở dữ liệu + Quét file lời bài hát + Dọn dẹp bộ nhớ đệm ảnh album + Đồng bộ nguồn cloud + Hoàn tất đồng bộ + %1$s • %2$d%% (%3$d/%4$d) + %1$s… + + + Chủ đề toàn cục + Ngôn ngữ ứng dụng + Chọn ngôn ngữ sử dụng trong giao diện ứng dụng. + Mặc định hệ thống + English + Español + Deutsch + Français + Русский + 简体中文 + Bahasa Indonesia + Italiano + 日本語 + 한국어 + Norwegian (Bokmål) + 繁體中文 + Türkçe + Tiếng Việt + Chủ đề ứng dụng + Chuyển đổi giữa sáng, tối hoặc theo giao diện hệ thống. + Chủ đề sáng + Chủ đề tối + Theo hệ thống + Sử dụng góc mềm + Sử dụng góc có hình dạng phức tạp giúp cải thiện thẩm mỹ nhưng có thể ảnh hưởng hiệu suất trên thiết bị thấp + Tắt hiệu ứng mờ + Tắt hiệu ứng mờ trong toàn bộ ứng dụng để tiết kiệm pin và tài nguyên. + Hiển thị thanh cuộn + Hiển thị thanh cuộn bên trên danh sách nhạc để cuộn nhanh + Đang phát + Chủ đề trình phát + Chọn giao diện cho trình phát nổi. + Ảnh album + Động hệ thống + Hiển thị thông tin file trình phát + Hiển thị codec, bitrate và sample rate trong phần tiến trình trình phát. + Kiểu bảng màu ảnh album + Hiện tại: %1$s. Mở xem trước trực tiếp và chọn kiểu. + Kiểu carousel + Chọn giao diện cho carousel album. + Không lén nhìn + Một lần lén nhìn + Collage trang chủ + Mẫu collage + Chọn cách sắp xếp hình dạng cho collage Mix của bạn. + Tự động xoay mẫu + Đổi qua các mẫu collage mỗi lần bạn truy cập Trang chủ. + Thanh điều hướng + Kiểu NavBar + Chọn giao diện cho thanh điều hướng. + Mặc định + Đầy đủ chiều rộng + Chế độ thu gọn + Chỉ hiển thị biểu tượng và giảm chiều cao navbar. + Bán kính góc NavBar + Điều chỉnh bán kính góc của thanh điều hướng. + Màn hình lời bài hát + Lời bài hát đắm chìm + Tự động ẩn điều khiển và phóng to chữ. + Trì hoãn tự ẩn + Thời gian trước khi điều khiển bị ẩn. + 3s + 4s + 5s + 6s + Điều hướng ứng dụng + Tab mặc định + Chọn tab khởi chạy mặc định. + Trang chủ + Tìm kiếm + Thư viện + Điều hướng thư viện + Chọn cách di chuyển giữa các tab Thư viện. + Hàng tab (mặc định) + Viên thu gọn & lưới + + + Màu sắc + Kiểu bảng màu + Chọn màu album cho giao diện trình phát. + Điểm tông + Cân bằng và bình tĩnh. + Sống động + Sắc thái bão hòa cao. + Phóng khoáng + Đổi màu táo bạo và tương phản. + Salad trái cây + Sắc thái vui nhộn xoay vòng. + Độ chính xác màu + 0 giữ nguyên tinh chỉnh hiện tại. Giá trị cao hơn giữ gần với màu chủ đạo của ảnh album. + Hiện tại + Chính xác hơn + 0 • Hiện tại + %1$d • Nhẹ nhàng + %1$d • Cân bằng + %1$d • Chính xác + + + Điều chỉnh bán kính góc + Khớp các góc của hình dạng navbar với góc vật lý thiết bị của bạn để có vẻ liền mạch. + Bán kính góc + %1$d dp + + + Phát nền + Tiếp tục phát sau khi đóng + Nếu tắt, xóa ứng dụng khỏi danh sách gần đây sẽ dừng phát. + Tối ưu hóa pin + Tắt tối ưu hóa pin để ngăn chặn gián đoạn phát. + Tối ưu hóa pin đã bị tắt + Không thể mở cài đặt pin + Chuẩn hóa âm lượng (ReplayGain) + Bật ReplayGain + Chuẩn hóa mức âm lượng bằng metadata ReplayGain từ file âm thanh. + Chế độ Gain + Bài hát: chuẩn hóa mỗi bài. Album: chuẩn hóa mỗi album. + Bài hát + Album + Phản chiếu + Tự động phát khi kết nối/ngắt kết nối cast + Bắt đầu phát ngay sau khi chuyển đổi kết nối cast. + Tai nghe + Tiếp tục khi tai nghe kết nối lại + Nếu phát bị tạm dừng vì tháo tai nghe, tự động tiếp tục khi chúng kết nối lại. + Hàng đợi và chuyển đổi + Crossfade + Bật chuyển đổi mượt mà giữa các bài hát. + Thời lượng Crossfade + Chế độ Hi-Fi + Đầu ra âm thanh float 32-bit. Tắt nếu phát bị giật trên thiết bị của bạn. + Không được hỗ trợ trên thiết bị này (PCM_FLOAT AudioTrack không khả dụng). + Trộn bài bền vững + Nhớ cài đặt trộn bài ngay cả sau khi đóng ứng dụng. + Hiển thị lịch sử hàng đợi + Hiển thị các bài hát đã phát trước đó trong hàng đợi. + + + Thư mục + Cử chỉ quay lại kiểm soát thư mục + Trong tab Thư mục, quay lại hệ thống điều hướng ngăn xếp thư mục trước khi rời Thư viện. + Cử chỉ trình phát + Nhấn nền đóng trình phát + Nhấn vào nền mờ để đóng sheet trình phát. + Cảm ứng + Phản hồi cảm ứng + Bật phản hồi rung trong toàn bộ ứng dụng. + + + Nhà cung cấp AI + Nhà cung cấp + Chọn nhà cung cấp AI của bạn + Chế độ Token An toàn + BẬT — Nhanh & rẻ. Gửi dữ liệu tối thiểu (~1K token) đến AI. + TẮT — Ngữ cảnh sâu. Gửi toàn bộ hồ sơ nghe (~8K token) để có kết quả phong phú hơn. + Thông tin đăng nhập + API Key %1$s + Lấy từ %1$s + Google AI Studio (aistudio.google.com) + DeepSeek Platform (api.deepseek.com) + Groq Console (console.groq.com) + Mistral AI Platform (console.mistral.ai) + NVIDIA Build (build.nvidia.com) + Moonshot AI Platform (platform.moonshot.cn) + Zhipu AI Open Platform (bigmodel.cn) + OpenAI Platform (platform.openai.com) + Chọn mô hình + Đang tải mô hình khả dụng… + Không thể tải mô hình + Mô hình AI + Chọn một mô hình. + Nhập API Key + Hành vi Prompt + Prompt hệ thống + Tùy chỉnh cách AI hoạt động. + Prompt có sẵn + Nhập prompt hệ thống… + Chuyên gia tuyển chọn chuyên nghiệp + Bạn là \'Vibe-Engine\', một chuyên gia tuyển chọn nhạc đẳng cấp thế giới và bậc thầy về dòng chảy âm thanh. Mục tiêu của bạn là xây dựng trải nghiệm nghe liền mạch, chất lượng cao. Ưu tiên sự hài hòa, chuyển đổi BPM logic và sự cân bằng tinh tế giữa các bài yêu thích quen thuộc và khám phá có gu, dựa trên logic. + Nhà khám phá sáng tạo + Bạn là nhà thám hiểm âm nhạc tiên phong chuyên về \'sự gắn kết bất ngờ\'. Sứ mệnh của bạn là phá vỡ ranh giới thể loại truyền thống bằng cách xác định các điểm tương đồng âm thanh không obvious. Ưu tiên các bản cắt sâu hiếm có, kết cấu thử nghiệm và sự mới lạ nghệ thuật trong khi duy trì logic chuyển đổi bất ngờ nhưng không thể phủ nhận. + Thủ thư nghiêm ngặt + Bạn là kiến trúc sư cơ sở dữ liệu âm nhạc chính xác. Logic của bạn được thúc đẩy bởi độ chính xác metadata tuyệt đối và sự tuân thủ danh mục nghiêm ngặt. Giảm thiểu khám phá алгоритм để ủng hộ sự nhất quán thể loại nghiêm ngặt, sự khớp_LEVEL năng lượng và tối đa hóa việc truy xuất các sở thích được người dùng xác định rất cụ thể. + Hướng dẫn viên không khí + Bạn là bậc thầy về kết cấu môi trường và dòng chảy năng lượng thấp. Tập trung độc quyền vào các bản nhạc tạo điều kiện cho trạng thái \'tập trung sâu\' hoặc \'bình yên\'. Ưu tiên sự ấm áp âm thanh, sắp xếp tối giản và chuyển đổi nhẹ nhàng, đồng thời tránh nghiêm ngặt các âm thanh transient cao hoặc thay đổi đột ngột trong phạm vi động. + Người đam mê âm thanh + Bạn là nhà phân tích âm thanh tập trung vào độ phức tạp sản xuất và nhạc cụ. Ưu tiên các bản nhạc được đặc trưng bởi phạm vi động cao, polyrhythm phức tạp và chất lượng soundstage vượt trội. Ưa thích các bản nhạc nghe tích cực thưởng cho người nghe khi chú ý đến độ trung thực kỹ thuật và chi tiết sắp xếp. + Chất xúc tác năng lượng + Bạn là máy tạo nhịp động lượng cao. Triết lý của bạn tập trung vào đường bass mạnh mẽ, cường độ gõ và groove lây lan. Ưu tiên khả năng tương thích câu lạc bộ BPM cao, năng lượng syncopated và căng thẳng nhịp liên tục để giữ nhịp tim và động lực ở mức cao nhất của người nghe. + Báo cáo sử dụng AI + Tổng tiêu thụ + Theo dõi %1$s token\nPrompt: %2$s | Đầu ra: %3$s | Suy nghĩ: %4$s + Xóa nhật ký + Nhật ký hoạt động AI (%1$d) + %1$s · %2$s + Hiển thị + Ẩn + + + Sao lưu hoạt động như thế nào + Chọn các phần, xuất file .pxpl và nhập sau. Khôi phục chỉ thay thế các phần bạn chọn. + Tạo sao lưu + Xuất sao lưu + Chưa chọn phần nào. + Đã chọn tất cả phần. + Đã chọn %1$d trong số %2$d phần. + %1$s Tạo file sao lưu .pxpl. + Chọn & Xuất + Khôi phục sao lưu + Nhập sao lưu + Chọn & Khôi phục + Duyệt hoặc chọn từ các bản sao lưu gần đây. Dữ liệu được chọn sẽ thay thế dữ liệu hiện tại. + + + Chọn chính xác những gì bạn muốn đưa vào gói sao lưu. + Chọn file sao lưu .pxpl để kiểm tra. Bạn sẽ chọn các phần nào để khôi phục ở bước tiếp theo. + Đã chọn %1$d trong số %2$d phần + Đã chọn %1$d trong số %2$d mô-đun + Sao lưu gần đây + Không có bản sao lưu gần đây + Các bản sao lưu đã nhập trước đó sẽ xuất hiện ở đây. + %1$d mục · Sẽ thay thế dữ liệu hiện tại + Xuất .pxpl + Khôi phục đã chọn + Đang truyền… + PixelPlayer_Backup_%1$d.pxpl + Đang tạo sao lưu + Đang khôi phục sao lưu + %1$d%% + %1$s • %2$s + Đang xuất + Đang nhập + Đang khôi phục + Xóa khỏi lịch sử + Đang kiểm tra… + Duyệt file + Bước %1$d trong số %2$d + Khôi phục mô-đun + Chi tiết sao lưu + Ngày tạo + Phiên bản ứng dụng + Schema + Thiết bị + Không xác định + · %1$s + %1$d mô-đun · v%2$s · schema v%3$d + \? + Chọn tất cả + Xóa lựa chọn + + + Sao lưu không hợp lệ: %1$s + Chuẩn bị khôi phục + Bắt đầu tác vụ khôi phục. + Chuẩn bị sao lưu + Bắt đầu tác vụ sao lưu. + Khôi phục sao lưu thành công + Khôi phục hoàn tất với một số vấn đề chưa giải quyết. + Không thể hoàn tất khôi phục: %1$s + Khôi phục thất bại: %1$s + Xuất dữ liệu thành công + Xuất thất bại: %1$s + Khôi phục dữ liệu thành công + Khôi phục hoàn tất với các vấn đề chưa giải quyết. Thất bại: %1$s + v%1$d + %1$s %2$s + + + Thử nghiệm + Thử nghiệm + Các experiment tải UI trình phát và công tắc. + Luồng cài đặt kiểm tra + Khởi chạy màn hình cài đặt onboarding để kiểm tra. + Bảo trì + Bắt buộc tạo lại Mix hàng ngày + Tạo lại danh sách phát mix hàng ngày ngay lập tức. + Tạo lại Mix hàng ngày + Tạo lại Mix hàng ngày? + Điều này sẽ loại bỏ mix hiện tại và tạo bản mới dựa trên thói quen nghe gần đây. + Đã bắt đầu tạo lại Mix hàng ngày + Bắt buộc tạo lại thống kê + Xóa bộ nhớ đệm và tính lại thống kê phát. + Tạo lại + Đang xử lý… + Tạo lại thống kê + Tạo lại thống kê? + Điều này sẽ xóa bộ nhớ đệm thống kê và bắt buộc tính lại từ lịch sử cơ sở dữ liệu. + Đã bắt đầu tạo lại thống kê + Bắt buộc tạo lại bảng màu album + Xây dựng lại tất cả biến thể bảng màu đã đệm cho mọi ảnh album, hoặc chọn một ảnh để làm mới. + Tạo lại tất cả + Tạo lại tất cả bảng màu album? + Điều này sẽ xóa dữ liệu chủ đề đã đệm và xây dựng lại tất cả kiểu bảng màu cho %1$d ảnh album duy nhất. + Đang tạo lại… + Đang tạo lại bảng màu album… + Xây dựng lại biến thể bảng màu đã đệm cho %1$d ảnh album duy nhất. Điều này có thể mất thời gian trên thư viện lớn. + Đã hoàn tất %1$d trong số %2$d + Đã tạo lại %1$d bảng màu ảnh album + Đã tạo lại %1$d trong số %2$d bảng màu ảnh album + Chọn bài hát + Chọn một bài hát để xóa dữ liệu chủ đề đã đệm và tạo lại tất cả kiểu bảng màu từ ảnh album. + Tìm theo tiêu đề, nghệ sĩ hoặc album + Không có bài hát nào khớp với tìm kiếm của bạn. + Không tìm thấy bài hát nào có ảnh album. + Đang tạo lại bảng màu… + Đã tạo lại bảng màu cho %1$s + Không thể tạo lại bảng màu cho %1$s + Chẩn đoán + Kích hoạt lỗi thử nghiệm + Mô phỏng lỗi để kiểm tra hệ thống báo cáo lỗi. + Lỗi thử nghiệm được kích hoạt từ Tùy chọn nhà phát triển - Đây là hành động có chủ đích để kiểm tra hệ thống báo cáo lỗi + + + Thử nghiệm + Các experiment tải PlayerUI + Lời bài hát động (Thiết bị cao cấp) + Sử dụng spring animation và hiệu ứng thị giác cho lời bài hát. Có thể gây tụt khung hình trên thiết bị thấp. + Hiệu ứng mờ lời bài hát + Áp dụng độ mờ trường ảnh cho lời bài hát không hoạt động. + Cường độ mờ + Điều chỉnh cường độ hiệu ứng mờ. + %1$.1fx + Bước 1 · Chọn những gì để trì hoãn + Trì hoãn mọi thứ + Giữ toàn bộ nội dung trình phát cho đến khi nền sheet được mở rộng hoàn toàn. + Carousel album + Trì hoãn ảnh album và carousel cho đến khi sheet được mở rộng. + Metadata bài hát + Trì hoãn tiêu đề, nghệ sĩ và hành động lời bài hát/hàng đợi. + Thanh tiến trình + Trì hoãn đường thời gian và nhãn thời gian cho đến khi mở rộng hoàn tất. + Điều khiển phát lại + Trì hoãn phát/tạm dừng, tìm nhanh và điều khiển yêu thích. + Tất cả thành phần bị trì hoãn đều đang hoạt động. Tắt \"Trì hoãn mọi thứ\" để tùy chỉnh từng phần. + Bước 2 · Cấu hình hành vi placeholder + Sử dụng placeholder cho các mục bị trì hoãn + Giữ bố cục ổn định bằng cách kết xuất placeholder nhẹ trong khi các thành phần chờ mở rộng. + Bước 3 · Chọn khi nào placeholder chuyển sang nội dung thực + Chọn một chế độ. Chế độ ngưỡng sử dụng thanh trượt; chế độ Thả kéo chờ cho đến khi bạn nhả cử chỉ sheet. + Bật ít nhất một thành phần bị trì hoãn để mở khóa chế độ kích hoạt. + Ngưỡng + Sử dụng phần trăm mở rộng. + Thả kéo + Chỉ chuyển đổi sau khi nhả cử chỉ. + Ngưỡng mở rộng + Sheet phải mở rộng bao nhiêu trước khi các thành phần bị trì hoãn hiển thị. + Nội dung xuất hiện ở %1$d%% mở rộng + Cũng áp dụng khi đóng trình phát + Sử dụng ngưỡng đóng để chuyển sang placeholder trong khi thu gọn. + Ngưỡng đóng + Cần thu gọn bao nhiêu trước khi placeholder tiếp quản lại. + Placeholder xuất hiện sau %1$d%% thu gọn + Chế độ thả kéo bỏ qua ngưỡng và hành vi đóng. Sự hoán đổi chỉ xảy ra khi cử chỉ kéo sheet kết thúc. + Làm placeholder trong suốt + Placeholder giữ không gian bố cục nhưng trở nên vô hình. + Chất lượng hình ảnh + Độ phân giải ảnh album + Thấp (256px) - Hiệu suất tốt hơn + Trung bình (512px) - Cân bằng + Cao (800px) - Chất lượng tốt nhất + Gốc - Chất lượng tối đa + + + Phát lại cần xem xét + Sẵn sàng phát lại + -- + Định dạng + Bộ giải mã HW + Bài hát cục bộ + Lưu trữ nhạc cục bộ + Kích thước nhạc + %1$d bài hát cục bộ + Khả dụng + %1$s tổng cộng + Dung lượng nhạc + Thiết bị đã dùng + %1$d%% + <1% + %1$d bài hát cloud + %1$d file không thể đọc + Đường dẫn phát lại + + Không + Tần số mẫu + %1$d Hz + %1$d khung mỗi bộ đệm + Hi-Fi PCM Float + Đường đầu ra float 32-bit + Hỗ trợ độ trễ thấp + Hỗ trợ Pro Audio + Bộ nhớ + khả dụng trong %1$s + Định dạng sẵn sàng offload + Không có định dạng nén nào báo cáo hỗ trợ offload phần cứng. + +%1$d thêm + Đầu ra được phát hiện + Đầu ra tích hợp + Âm thanh Bluetooth + Âm thanh USB + Âm thanh có dây + Đầu ra kỹ thuật số + Đầu ra khác + Không có tuyến đầu ra nào được Android báo cáo. + Trình phát ExoPlayer + %1$s bộ kết xuất + Tương thích định dạng + %1$d bài hát được hỗ trợ + %1$d định dạng không xác định + Không có bộ giải mã nào được báo cáo + Bộ giải mã phần cứng + Bộ giải mã phần mềm + Offload + %1$d trong thư viện + Kết quả tương thích + Không có bất tương thích lớn + Các bài hát đã lập chỉ mục của bạn khớp với các bộ giải mã Android báo cáo trên thiết bị này. + %1$d bài hát có thể không giải mã gốc + Định dạng cần xem xét: %1$s. + %1$d bài hát cục bộ có thể được lấy mẫu lại + Thư viện đạt %1$d Hz, cao hơn tần số mẫu đầu ra hiện tại. + %1$d bài hát có metadata không xác định + Một lần quét lại thư viện đầy đủ có thể điền dữ liệu MIME, bitrate và tần số mẫu bị thiếu. + Thông tin thiết bị + Nhà sản xuất + Mẫu + Thương hiệu + Thiết bị + Phiên bản Android + Phiên bản SDK + Phần cứng + Báo cáo hiệu suất + Tạo báo cáo chẩn đoán có thể chia sẻ để giúp chúng tôi phân loại lỗi phát lại hoặc quét chậm. Nó chỉ chứa dữ liệu thiết bị, thư viện và thời gian — không có đường dẫn file, tiêu đề hay nghệ sĩ. + Tạo báo cáo + Tạo lại + Sao chép + Chia sẻ + Đã sao chép báo cáo vào clipboard + Báo cáo hiệu suất PixelPlayer + Chẩn đoán hiệu suất nâng cao + Tắt theo mặc định. Ghi lại dòng thời gian lỗi ngắn để sửa lỗi beta. + Hoạt động cho đến %1$s + Đánh dấu lỗi ngay + Đã đánh dấu thời điểm lỗi + + + Tài khoản đã kết nối + Quản lý các nhà cung cấp đã liên kết và giữ mỗi tích hợp dưới sự kiểm soát của bạn. + Dịch vụ đã liên kết + Đang hoạt động + Khả dụng + Sắp ra mắt + Đã kết nối + Sắp ra mắt + Mở dịch vụ + Đang đăng xuất… + Chưa có tài khoản liên kết + Kết nối một nhà cung cấp để quản lý từ màn hình này. + Kết nối %1$s + %1$s (Sắp ra mắt) + Google Drive sắp ra mắt. + Không thể mở màn hình này lúc này. + + + Giới thiệu + PixelPlayer + Trình phát nhạc mã nguồn mở được xây dựng cùng cộng đồng. + Phiên bản v%1$s + Mã nguồn mở + Cộng đồng ưu tiên + Material 3 phóng khoáng + Không tìm thấy đóng góp nào lúc này. Vui lòng thử lại sau. + Người duy trì + Người đứng sau PixelPlayer. + Điểm nhấn cộng đồng + Ghi nhận những cộng tác viên có tác động lớn. + Giấy phép và thông báo + Ghi nhận cho các phụ thuộc và đóng góp trước đó. + Giấy phép mã nguồn mở + Giấy phép phụ thuộc và thông báo bên thứ ba + Giấy phép mã nguồn mở + Thông báo kiểu MIT + Thông báo bên thứ ba + Đóng + Không thể tải thông báo bên thứ ba được đóng gói. + Đóng góp mã nguồn mở + Danh sách đóng góp trực tuyến từ GitHub. + %1$d đóng góp. + GitHub + Kho lưu trữ + Telegram + Hỗ trợ + Mở kho lưu trữ GitHub + Tham gia cộng đồng Telegram + Mở hồ sơ GitHub + Mở Telegram + Ảnh đại diện của %1$s + Biểu tượng của %1$s + Âm lượng + Tạm dừng khi âm lượng về không + Tự động tạm dừng phát lại khi âm lượng được đặt ở 0 + diff --git a/app/src/main/res/values-vi/strings_widget.xml b/app/src/main/res/values-vi/strings_widget.xml new file mode 100644 index 0000000000..b8059dc969 --- /dev/null +++ b/app/src/main/res/values-vi/strings_widget.xml @@ -0,0 +1,17 @@ + + + Widget thích ứng với kích thước + Thanh trình phát thu gọn + Điều khiển đầy đủ với phát ngẫu nhiên và lặp lại + Trình phát hình vuông tối giản + + Nhấn để mở + Ảnh bìa album + Placeholder ảnh bìa album + + Nhấn để phát + Tên bài hát + Nghệ sĩ + + Thanh tiến trình, %1$d phần trăm + diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index daf7d4eac8..6849662a7e 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -10,7 +10,9 @@ 行为 手势、触感反馈和导航行为 AI 集成 (β) - AI 提供商、API 密钥和模型设置 + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration 备份与恢复 导出和恢复您的个人应用数据 开发者选项 @@ -174,6 +176,9 @@ 韩语 挪威语(Bokmål) 土耳其语 + 日语 + 繁体中文 + 越南语 应用主题 在浅色、深色之间切换,或跟随系统外观。 浅色主题 diff --git a/app/src/main/res/values-zh-rTW/plurals.xml b/app/src/main/res/values-zh-rTW/plurals.xml new file mode 100644 index 0000000000..a1bf52e6e2 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/plurals.xml @@ -0,0 +1,45 @@ + + + + 正在分享 %d 個播放清單 + + + 已將 %1$d 個播放清單匯出至 %2$s + + + 已將 %d 首歌曲加入佇列 + + + %d 首歌曲將接著播放 + + + 已將 %d 首歌曲加入最愛 + + + 已將 %d 首歌曲從最愛移除 + + + 已刪除 %d 個檔案 + + + 刪除 %d 個檔案? + + + 已選擇 %d 首歌曲 + + + %1$d 首歌曲 • %2$s + + + %d 首歌曲已排入佇列。 + + + 已選擇 %d 首歌曲 + + + %d 首歌曲 + + + %d 個主題 + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..0fca03d251 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,128 @@ + + + PixelPlayer + 音樂播放器 + 應用名稱更改 + 由於商標相關問題,我們將應用名稱從 PixelPlay 更改為 PixelPlayer。請繼續享受音樂! + 不再顯示 + + + 首頁 + 搜索 + 音樂庫 + + + 需要特殊權限 + 爲了編輯歌曲信息(.mp3 文件),PixelPlayer 需要訪問所有文件的特殊權限。這將允許我們直接修改曲目標籤。請在下一界面授予此權限以開啓信息編輯功能。 + 授予權限 + + + 快速播放 + 無法打開該音頻文件。 + 打開完整播放器 + + + 隨機播放 + 隨機播放所有歌曲 + 全部隨機播放 + 最近播放列表 + 沒有可打開的播放列表 + + + 打開 Play 商店 + 繼續使用 Beta 版 + Play 商店鏈接將通過 GitHub 配置啓用。 + PixelPlayer 現已上線 Google Play + 請在 Google Play 上使用穩定版以獲取正式更新,同時我們仍會保持 Beta 測試版本的更新。 + PixelPlayer + 發佈公告 + 敬請期待 + + + 感謝您使用 PixelPlayer! + 最高分 %1$d + 關閉 + 分數 + 第 %1$d 關 + 生命 + 關卡完成! + 遊戲結束 + 分數:%1$d + 再試一次? + 下一關 + 重新開始遊戲 + 點擊重新啓動 + 隨機播放音樂 + 打磚塊 + 最高分 %1$d + 開始 + 拖動以移動擋板 + + + 關閉播放器 + 正在處理播放操作… + 播放錯誤:%1$s + + + 返回 + 確定 + 取消 + 關閉 + 錯誤 + 搜索 + 清除搜索 + 全部 + 確認 + 已保存! + 已選擇 + %1$d%% + 藝術家 + 全選 + 清除 + 未知錯誤 + + + 保存 + 完成 + 重置 + 應用 + 隨機播放 + 複製 + 分享 + 撤銷 + 導入 + 刪除 + 導出 + 合併 + 重命名 + 創建 + 歌詞 + 設置 + 專輯封面 + 播放列表 + 未知曲目 + 未知藝術家 + 未知專輯 + 關閉 + 添加 + 移除 + 播放 + 上一首 + 下一首 + 收藏 + 暫停 + 循環播放 + 選項 + 隨機播放 + %1$s 的更多選項 + 展開菜單 + 下一步 + 結束 + 恢復默認設置 + 導出全部 + 合併全部 + 分享全部 + 播放專輯 + 隨機播放專輯 + %1$s 的專輯封面 + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings_changelogs.xml b/app/src/main/res/values-zh-rTW/strings_changelogs.xml new file mode 100644 index 0000000000..6055745830 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_changelogs.xml @@ -0,0 +1,132 @@ + + + 更新日誌 + 在 GitHub 上查看 + 改進 + 修復 + 新功能 + 新增 + + 支持 Chromecast 從您的設備投射音頻。 + 應用內更新日誌,讓您隨時瞭解最新功能。 + 支持嵌入式和外部的 .LRC 歌詞文件。 + 支持離線歌詞。 + 同步歌詞(與歌曲同步)。 + 查看完整隊列的新界面。 + 支持在隊列中重新排序和移除歌曲。 + 迷你播放器手勢(向下滑動關閉)。 + 添加了更多 Material 動畫。 + 新增設置項以自定義外觀。 + 新增設置項以清除緩存。 + + + 用戶界面的全面重新設計。 + 播放器的全面重新設計。 + 媒體庫性能改進。 + 提升了應用的啓動速度。 + AI 現在能提供更好的結果。 + + + 修復了標籤編輯器中的各種錯誤。 + 修復了播放通知未被清除的錯誤。 + 修復了導致應用崩潰的幾個錯誤。 + + + 引入了更豐富的聽歌統計中心,可深入瞭解您的播放會話。 + 推出了懸浮快速播放器,可立即打開並預覽本地文件。 + 添加了包含樹狀導航器 and “播放列表就緒”視圖的文件夾標籤頁。 + + + 改進了整體 Material 3 UI,帶來更簡潔、更連貫的體驗。 + 元數據編輯現已支持更改封面。 + 優化了應用內的動畫和過渡效果,使導航更加流暢。 + 增強了藝術家界面的佈局,細節更豐富且更精美。 + 升級了 DailyMix 和 YourMix 的生成,選擇更加智能且多樣化。 + 強化了 AI 播放列表的生成能力。 + 改進了搜索相關性和展示方式,以便更快地發現內容。 + 擴大了對更多音頻文件格式的支持。 + + + 解決了元數據異常問題,確保各處的歌曲詳情保持準確。 + 恢復了通知快捷操作,以便可靠地跳轉回播放界面。 + + + 導航欄重大重新設計 + 用於選擇源目錄的全新文件管理器 + 全新的連接與投屏功能 + 遠程設備之間的無縫流轉 + 歌曲間無縫過渡 + 淡入淡出控制 + 全新的自定義過渡功能(僅限播放列表) + 關閉應用後繼續播放 + UI 優化 + 統計功能改進 + 重新設計並增加了更多功能的隊列控制 + 改進了對不同文件格式的播放和元數據編輯支持 + 改進的權限控制器 + 修復了一些小錯誤 + + + Material 3 Expressive UI 全面升級 + 10 段均衡器與音效 + 全新的媒體庫同步流程 + AI 集成(Gemini 模型) + M3U 播放列表導入/導出 + 集成 Deezer 藝術家封面 + 自定義播放列表封面 + 設置架構重構 + 隊列與播放器動畫 + 基線配置文件 (Baseline Profiles) 與性能優化 + 具有同步偏移調整功能的更佳歌詞系統 + + + 投屏穩定性改進 + 播放面板穩定性 + 常規錯誤修復與代碼清理 + + + 現已支持 Android Auto 車載播放。 + 已支持 Wear OS,包括更完善的手錶對手機播放控制。 + 擴展了雲端集成,改進了對 Telegram、網易雲音樂、QQ 音樂和 Google Drive 的支持。 + “最近播放”和持久化隊列恢復功能,讓您的聽歌會話隨時就緒。 + 現已包含備份與恢復 v3 以及賬號管理工具。 + 歌詞功能更加智能,增加了手動搜索備用方案和存儲優化。 + + + 大幅優化了啓動、媒體庫、隊列和播放器交互的性能。 + 重新設計了播放器、投屏、歌詞、藝術家和流派界面,帶來更流暢的體驗。 + 導航和搜索流程更加可靠,路由處理更加安全。 + 提升了音頻播放在更多設備 and 格式上的兼容性。 + 擴展了歌曲、專輯和播放列表的多選操作流程。 + + + 隊列和隨機播放行爲現已更加穩定且符合預期。 + 修復了幾個後臺播放和投屏的邊緣情況。 + 修復了睡眠定時器、文件標籤頁導航以及導致專輯藝術家崩潰的問題。 + 改善了小組件加載和服務穩定性,減少了設備發熱/內存問題。 + 全局的常規錯誤修復和界面細節打磨。 + + + Wear OS:支持音樂傳輸、本地播放、隊列同步以及從手錶進行遠程控制。 + AI:集成了 Groq AI 和 OpenRouter(實驗性),並進行了 Token 優化。 + 雲端:新增了 Jellyfin 支持。 + 歌詞:帶有專屬開關的同步翻譯,支持酷狗 LRC 格式,自定義文本對齊方式,以及改進了遠程加載。 + UI/UX:緊湊的導航欄模式,從專輯封面提取調色板的動態主題,長標題跑馬燈效果,以及全新的排序選項。 + Telegram:原生話題 (Topics) 支持和增強的顯示模式。 + + + 音頻引擎:全面重構,支持更多格式(MIDI、ALAC、M4A)並進行了解碼器優化。 + 效率:大幅降低能耗,修復設備發熱問題,以及後臺任務優化 (SyncWorker)。 + 數據庫:大規模的查詢優化,並重新設計了封面緩存以防止數據丟失。 + 啓動:通過基線配置文件 (Baseline Profile) 優化了加載時間。 + + + 播放:修復了 Opus/MP3 的卡頓、淡入淡出期間的 ReplayGain 錯誤,以及三星解碼器上的啓動問題。 + 穩定性:消除了啓動、藝術家導航和 Android 12+ 設備上的崩潰問題。 + UI:修復了封面閃爍、非拉丁語系文本溢出,以及導航欄/迷你播放器的行爲問題。 + 安全性:強化了憑證處理、存儲權限以及與媒體服務器的通信安全性。 + + + 本地化:西班牙語、法語、俄語、簡體中文、印度尼西亞語、意大利語 + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings_cloud_services.xml b/app/src/main/res/values-zh-rTW/strings_cloud_services.xml new file mode 100644 index 0000000000..189b9eeebf --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_cloud_services.xml @@ -0,0 +1,228 @@ + + + + 登錄 Telegram + 您正在修改號碼。再次發送驗證碼將替換上一個。 + 處理中… + 正在初始化 Telegram… + 正在登出… + 正在關閉會話… + 會話已關閉。請重新打開登錄界面以繼續。 + 正在準備安全的 Telegram 會話… + 正在等待 Telegram 響應… + 連接 Telegram + 連接 Telegram 即可流式播放您頻道和聊天中的音樂。 + 手機號碼 + 請輸入您的 Telegram 手機號碼。您稍後可以返回並進行修改。 + 手機號碼 + 86 + 13800138000 + 發送驗證碼 + 驗證碼 + 請輸入來自 Telegram 的驗證碼。如果號碼有誤,請返回並修改。 + 驗證碼 + 12345 + 修改手機號 + 重新發送驗證碼 + 驗證驗證碼 + 兩步驗證密碼 + 請輸入您的 Telegram 密碼。您仍然可以返回以修改手機號碼。 + 密碼 + 驗證密碼 + 請稍候… + + + Telegram 頻道 + 添加頻道 + 公開的 Telegram 頻道 + 正在同步 + 立即同步 + 收起話題 + 顯示話題 + 頻道選項 + 話題 + 正在同步頻道 + 正在從 Telegram 更新歌曲 + 從此頻道獲取最新歌曲 + 移除頻道 + 停止同步並移除已緩存的歌曲 + 是否移除頻道? + %1$s 將停止同步,並且從此頻道緩存的所有歌曲都將被移除。 + 移除 + 未同步任何頻道 + 添加公開的 Telegram 頻道\n以同步您的音樂庫 + 添加頻道 + 從未同步 + 上次同步於 %1$s + + + 添加頻道 + 搜索一個公開的 Telegram 頻道以同步其音樂 + \@channelname 或鏈接 + 正在搜索… + 搜索頻道 + 輸入公開頻道的用戶名或鏈接\n以同步其音頻文件 + + + %d 首歌曲 + %d 首歌曲 + + + %d 個話題 + %d 個話題 + + + + Subsonic + 管理 Navidrome、Airsonic 以及其他兼容 Subsonic 的服務器。 + + + 點擊同步以獲取您的 Jellyfin 播放列表 + 管理您的 Jellyfin 服務器連接。 + + + 音樂文件夾 + 點擊 + 添加 Drive 文件夾 + 暫未添加文件夾 + 已同步 %1$d 個文件夾 + 添加文件夾 + + + 選擇播放列表類型 + 選擇要同步的播放列表: + 所有播放列表 + 自建與收藏 + 自建播放列表 + 收藏播放列表 + + + 已同步 %1$d 個播放列表 + 播放列表 + 同步 + 暫未同步任何播放列表 + 點擊同步以獲取您的播放列表 + 快捷操作 + 同步媒體庫 + 斷開連接 + %1$d 首歌曲 + + + 正在同步 + 正在同步媒體庫… + 正在獲取播放列表… + 正在同步播放列表:%1$s + 正在更新本地媒體庫… + 同步完成 + 正在獲取專輯列表… + 正在從 %1$s 獲取歌曲… + 正在將 %1$d 首歌曲保存到數據庫… + 未找到媒體庫歌曲 + 媒體庫同步完成 + 正在同步… + 錯誤:%1$s + + + 同步 + 全部同步 + 登出 + 同步所有播放列表 + 用戶頭像 + + + 無網絡連接 + 此內容需要網絡連接。請檢查您的網絡設置並重試。 + 您已離線 + 請檢查您的網絡連接並重試以訪問此內容。 + + + 連接 + 正在連接… + 輸入您的服務器地址和賬戶憑據。 + 連接詳情 + 隱藏密碼 + 密碼 + 輸入密碼 + 預填 http:// + 服務器 URL + 顯示密碼 + Telegram + 用戶名 + admin + 歡迎您,%1$s! + + + 兼容 Navidrome、Gonic、Airsonic 以及其他兼容 Subsonic 的服務器 + 支持 Navidrome、Airsonic、Gonic、Ampache 以及其他兼容 Subsonic API 的服務器。 + 如果您的服務器支持,應用密碼同樣有效。 + 預填 https:// + 連接到您的自託管音樂服務器 + Navidrome + 請使用您服務器的完整 https:// 基礎地址。 + https://music.example.com + 這是您的 Subsonic 或 Navidrome 賬戶名稱。 + Subsonic / Navidrome + Subsonic + + + 輸入您的 Jellyfin 服務器 URL 和賬戶憑據。 + 連接到 Jellyfin 服務器以串流播放您的媒體庫 + 連接到 Jellyfin 服務器。支持使用 HTTP 和 HTTPS 進行本地網絡訪問。 + Jellyfin + 您的 Jellyfin 賬戶密碼。 + 連接到您的 Jellyfin 媒體服務器 + Jellyfin + 您的 Jellyfin 服務器完整 URL,包含端口。 + http://192.168.1.100:8096 + 您的 Jellyfin 賬戶用戶名。 + + + 直接從您的 Google Drive 串流音樂文件 + 連接 Google Drive + Google Drive 已連接! + 創建 \"PixelPlayer Music\" + 在這裏爲您的音樂創建一個新文件夾 + 這裏沒有文件夾 + 打開文件夾 + 選擇或創建一個文件夾作爲您的音樂源 + 選擇一個音樂文件夾 + 正在設置 Google Drive… + 使用 Google 賬號登錄 + Google Drive + 使用 + + + 無法讀取會話 Cookie。 + 完成 + 退出 + 未找到 Cookie。請先登錄。 + 頁面加載時間過長。請刷新或嘗試其他網絡。 + + + 正在保存… + 停留 + 頁面加載超時。您可以重試,進度不會丟失。 + 網頁後退 + 您可以稍後返回。關閉時將丟棄當前頁面狀態。 + 網頁前進 + 刷新 + 重試 + 打開主頁 + WebView 加載失敗。 + + + 讀取網易雲音樂 Cookie 失敗:%1$s + 退出網易雲音樂登錄? + 加載網易雲音樂時出現 HTTP %1$d。 + 尚未檢測到登錄。請在點擊完成前完成網易雲音樂的登錄。 + 登錄網易雲音樂 + 安全提示:您的密碼僅在網易雲音樂網頁中輸入。PixelPlayer 僅存儲會話 Cookie (MUSIC_U) 以同步您的媒體庫。 + 網易雲音樂 + + + 讀取 QQ 音樂 Cookie 失敗:%1$s + 退出 QQ 音樂登錄? + 加載 QQ 音樂時出現 HTTP %1$d。 + 尚未檢測到登錄。請在點擊完成前完成 QQ 音樂的登錄。 + 登錄 QQ 音樂 + 安全提示:您的密碼僅在 QQ 音樂網頁中輸入。PixelPlayer 僅存儲會話 Cookie 以同步您的媒體庫。 + QQ 音樂 + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings_equalizer.xml b/app/src/main/res/values-zh-rTW/strings_equalizer.xml new file mode 100644 index 0000000000..7ca427ded4 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_equalizer.xml @@ -0,0 +1,57 @@ + + + + 名稱不能爲空 + 重命名 + + + 更改視圖模式 + 禁用均衡器 + 啓用均衡器 + 編輯 + 編輯預設 + 自定義預設 + 預設 + 更新 + 低音增強 + 虛擬音效 + 響度 + 不支持 + 此設備不支持 + 音量 + 頻率響應 + Hz + 低音 + 中低音 + 中高音 + 高音 + 低音 / 低頻 + 中音 / 高頻 + 第 %1$d 頁 + 重置持續時間 + 保存爲新預設 + + + 已保存的預設 + 暫未保存自定義預設。 + 取消固定 + 固定 + 重命名 + 刪除 + + + 保存自定義預設 + 爲您的自定義均衡器預設輸入一個名稱。 + 預設名稱 + 重命名預設 + + + 管理預設 + 拖動以重新排序 • 點擊眼睛圖標顯示或隱藏 + 重新排序 + 重置預設 + 這將恢復默認的預設順序和可見性。是否繼續? + 恢復默認 + 可見 + 隱藏 + diff --git a/app/src/main/res/values-zh-rTW/strings_home_screen.xml b/app/src/main/res/values-zh-rTW/strings_home_screen.xml new file mode 100644 index 0000000000..78446dd4bc --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_home_screen.xml @@ -0,0 +1,277 @@ + + + + β + Beta + 雲端串流 + 更新日誌 + 雲端串流 + 從您的雲端賬戶串流音樂 + + + Beta 0.7.5 + β + 歡迎使用 PixelPlayer 0.7.5-beta + 您正在使用的是可能包含錯誤、崩潰或實驗性功能的測試版本。請報告問題以幫助我們改進。 + 預期情況 + 可能會意外出現錯誤、崩潰或未完成的功能。 + 某些功能可能會在不另行通知的情況下更改或移除。 + 測試版可能比正式發佈版本更不穩定。 + 在報告已知問題之前,請務必先檢查更新。 + 測試版在測試期間可能會更改、損壞或改進的內容。 + GitHub Issue 快捷入口 + 請先搜索,然後針對錯誤、崩潰、請求或問題開啓一個明確的報告。 + 瀏覽現有的 Issue + 報告問題或崩潰 + 分享復現步驟、預期結果、實際結果以及您的設備/操作系統詳細信息。 + 如何報告 + 開啓新 Issue 前的快速檢查清單。 + 開啓 Issue 前 + 搜索現有已開啓和已關閉的 Issue,以避免重複提交。 + 更新至 PixelPlayer 最新版本並確認問題是否仍然存在。 + 重啓應用並確認問題是否仍然存在。 + 嘗試復現它並寫下確切的步驟。 + 哪種問題類型? + 錯誤報告:某些功能表現異常。 + 功能請求:添加新功能或改進。 + 提問:如果已啓用,請使用 Discussions(討論),或開啓一個帶有問題標籤的 Issue。 + 錯誤報告 + 當某些功能表現異常或崩潰時,請複製這些字段。 + 錯誤報告 + 簡要概述: + 預期行爲: + 當前行爲: + 播放/復現步驟:1. 2. 3. + 發生的頻率如何?總是 / 有時 / 很少。 + 截圖/視頻:如果有。 + 日誌/堆棧跟蹤:如果有。 + 環境 + PixelPlayer 版本: + 安裝來源:GitHub Release、debug 構建、Nightly 構建等。 + Android 版本: + 設備型號: + 額外信息:SD 卡使用情況、特殊設置、權限等。 + 功能請求 + 當您希望添加新功能或改進時,請複製這些字段。 + 問題描述:您想解決什麼問題? + 建議方案:它應該如何工作? + 考慮過的替代方案:是否有其他方法? + 影響範圍:哪些界面或流程會受影響? + 樣圖或參考圖片(如果有)。 + 標題、隱私和範圍 + 使報告易於分類且安全共享。 + 好的 Issue 標題示例 + 均衡器:切換預設選項卡時指示器偏移 + 搜索:查詢爲空時歷史記錄列表不顯示 + 功能:添加“最近添加”播放列表排序選項 + 請避免 + 諸如“它不起作用”之類的通用報告。 + 在一個 Issue 中包含多個不相關的問題。 + 包含私人數據的未經打碼處理的日誌或截圖。 + 隱私聲明 + 在發佈日誌、截圖或視頻之前,請刪除個人或私人信息。 + Nightly 構建版本 + Nightly 版本與正式版有何不同,以及出錯時應提供哪些信息。 + Nightly 版本是從最新提交 (commit) 生成的,可能包含未完成的更改、臨時錯誤或迴歸。它們比正式發佈版本更具實驗性。 + 如果可用,可從倉庫的 GitHub Actions 工作流產物 (artifacts) 中獲取。 + 報告 Nightly 問題 + 在報告 Nightly 版本的問題時,請務必說明該問題發生於 Nightly 版本,而非正式發佈版本。請儘可能提供構建日期、工作流運行名稱或編號,或者提交 SHA。並請檢查最新的正式版本中是否會出現相同問題。 + Beta 0.5.0 升級 + 建議全新安裝 + 如果您從 Beta 0.5.0 升級,此更新可能需要全新的媒體庫數據,而不是舊的緩存狀態。 + 如果元數據或媒體庫條目看起來有誤 + 歌曲元數據錯誤、藝術家或專輯不匹配,或出現類似重複的條目,通常意味着只需全新安裝即可解決。 + 不再顯示 + 知道了 + + + 糟糕!出錯了 + 應用在您上次使用時崩潰了。請分享崩潰報告以幫助我們修復此問題。 + 日期:%1$s + 錯誤: + 堆棧跟蹤(預覽): + 崩潰日誌 + 崩潰日誌已複製到剪貼板 + PixelPlayer 崩潰報告 + 分享崩潰報告 + + + DJ 混音 + + + 你的\n推薦 + 暫無數據 + 當 PixelPlayer 發現歌曲或同步數據源時,您的推薦將顯示在此處。 + 刷新 + + + DAILY MIX + 基於聽歌歷史 + 查看全部 Daily Mix + Daily Mix + + + Daily Mix + + %1$d 首歌曲 • %2$s + %1$d 首歌曲 • %2$s + + 播放 + AI 播放列表生成器 + + + 您的 Daily Mix 是如何生成的 + 您的 Daily Mix 由您最喜愛和播放次數最多的歌曲組成。我們還會添加您喜歡的藝術家和流派的曲目,讓您發現新音樂。 + 告訴 AI 您今天想聽什麼 + 我們使用小樣本以保持較低的成本 + 正在更新… + 更新 Daily Mix + + + 完美精選 + Daily Mix + 您的聲音之旅已就緒 + AI 播放列表生成器 + 描述您的氛圍、心情或活動,讓 AI 從您的媒體庫中精心挑選完美的播放列表。 + 播放列表大小 + 最少歌曲數 + 最多歌曲數 + 例如:輕鬆的夜晚氛圍,歡快的健身音樂… + 點擊重試 + 聲音之旅合成完畢! + 準備播放 + 正在生成… + 生成播放列表 + + + 最近播放 + + + 最近播放 + 播放最新 + 在 %1$s 內無最近播放記錄 + 更改時間範圍或播放更多歌曲以填充此時間軸。 + 最近播放 + 今天 + 昨天 + + + 聽歌統計 + 總播放量 + 日均播放 + 最愛曲目 + %1$s • %2$d 次播放 + + + 聽歌統計 + 刷新聽歌統計 + 今天 + 本週至今 + 本月至今 + 本年至今 + 全部時間 + 聽歌時長 + 播放次數 + 聽歌時間軸 + 聽歌時長 + 所選時間範圍內的總聽歌時長。 + 播放次數 + 您在每個時間段內完成的會話次數。 + 平均會話 + 每個時間段的平均聽歌時長。 + 劃分爲 4 小時的時間段,揭示您的日常節奏。 + 每日柱狀圖,方便比較每週習慣。 + 每週柱狀圖,顯示本月趨勢。 + 每月柱狀圖,顯示全年的季節性變化。 + 每年柱狀圖,總結您的完整聽歌歷史。 + 暫無聽歌數據 + 點擊播放開始建立您的聽歌時間軸 + 每日節奏 + 每週節奏 + 每月節奏 + 全年概覽 + 歷年進展 + 按 4 小時分段分組 + 按星期幾分組 + 按月內周次分組 + 按月分組 + 按年份分組 + 高峯時段 + %1$d 次播放 + + 熱門類別 + 比較您在各個流派、藝術家、專輯和歌曲中的聽歌偏好。 + 流派 + 藝術家 + 專輯 + 歌曲 + 按流派統計 + 按藝術家統計 + 按專輯統計 + 按歌曲統計 + %1$d 次播放 • %2$d 位藝術家 + %1$d 次播放 • %2$d 首曲目 + 暫無類別數據 + 點擊播放,您的聽歌亮點將在這裏浮現 + 聽歌習慣 + 暫無習慣數據 + 當我們更瞭解您時,就會在這裏展示您的聽歌習慣。 + 總會話數 + 平均會話時長 + 最長會話 + 會話數/天 + 最活躍的一天 + 暫無播放 + 時間軸最高峯 + 熱門藝術家 + 暫無熱門藝術家 + 繼續聽歌,您最喜愛的藝術家將出現在這裏。 + \? + %1$d. %2$s + 熱門專輯 + 暫無熱門專輯 + 您經常回顧的專輯將出現在這裏。 + %1$d. %2$s + 曲目集中度 + 您的聽歌時間在最愛曲目中的分佈情況。 + 暫無集中度數據 + 播放更多曲目即可瞭解您聽歌的專一程度。 + Top 1 + Top 2-3 + 其他 + %1$d%% + 聽歌集中度 + 排名前 3 的曲目佔據了您 %1$d%% 的聽歌時間。 + 平均播放/曲目 + 去重曲目數 + Top 3 佔比 + 此範圍內的曲目 + 選定時間範圍內播放次數最多的曲目。 + 暫無熱門曲目 + 聽您喜歡的歌,它們將會在這裏高亮顯示。 + 收起曲目 + 顯示所有曲目 + + + %1$d 小時 %2$02d 分鐘 + %1$d 分鐘 + %1$d 小時 %2$02d 分 + %1$d 小時 + %1$d 分 + %1$d 秒 + %1$d小時%2$02d分 + %1$d小時 + %1$d分 + %1$d秒 + 從未 + 剛剛 + 1 天前 + %1$d 天前 + 1 小時前 + %1$d 小時前 + 1 分鐘前 + %1$d 分鐘前 + %1$d 首歌曲 + %1$d 首歌曲 + 第 %1$d 周 + diff --git a/app/src/main/res/values-zh-rTW/strings_library.xml b/app/src/main/res/values-zh-rTW/strings_library.xml new file mode 100644 index 0000000000..42dac71316 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_library.xml @@ -0,0 +1,566 @@ + + + + 音樂庫 + 媒體庫選項卡 + 直接跳轉到任意選項卡或對它們進行重新排序。 + 重新排序選項卡 + + + 歌曲 + 專輯 + 藝術家 + 播放列表 + 文件夾 + 已收藏 + + + 播放列表創建成功 + 請先設置您的 AI 提供商 API 密鑰 + 請先設置您的 Gemini API 密鑰 + 已添加到隊列 + 下一首播放 + + + 手錶傳輸 + 設置 + 編輯 + 重新排序選項卡 + 展開菜單 + + + 您最多可以選擇 %1$d 張專輯 + 文件夾 + 文件夾 + + + 排序方式 + 視圖 + 播放列表視圖 + 網格 + 列表 + 內部存儲 + SD 卡 + SD 卡目前不可用。 + 雲端 + Telegram 雲端頻道 + 話題顯示 (Topics) + 頻道 + 話題 + 全部 + 雲端 + 僅雲端 + + + 正在使用 AI 生成元數據… + + + 加載歌曲時出錯 + 加載專輯時出錯 + 加載藝術家時出錯 + 重試 + + + 在您的媒體庫中未找到歌曲。 + 如果您設備上有音樂,請嘗試在設置中重新掃描媒體庫。 + 未找到歌曲 + + + 新建 + 創建新播放列表 + 導入 M3U 播放列表 + 定位當前歌曲 + 所有歌曲 + 雲端 + 本地 + 排序選項 + + + 全選 + 取消選擇 + 更多選項 + + + 正在掃描音樂文件… + 正在處理文件… + 第 %1$d 個,共 %2$d 個文件 + 正在同步媒體庫… + 同步完成 + 等待中… + 正在同步媒體庫… + 正在清理專輯封面緩存… + 正在同步雲端數據源… + 正在掃描歌詞… + + + 暫無歌曲 + 將音樂添加到您的設備或同步雲端數據源以開始收聽。 + 未找到本地歌曲 + 嘗試其他數據源過濾或重新掃描您的設備媒體庫。 + 未找到雲端歌曲 + 同步 Telegram 或網易雲音樂歌曲,或切換到本地源。 + 暫無專輯 + 一旦您的媒體庫對曲目進行分組,專輯就會顯示在這裏。 + 未找到本地專輯 + 需要本地歌曲才能構建本地專輯組。 + 未找到雲端專輯 + 同步後,帶有專輯數據的雲端歌曲將顯示在這裏。 + 暫無藝術家 + 從任何來源爲歌曲建立索引後,將顯示藝術家。 + 未找到本地藝術家 + 目前沒有本地歌曲的藝術家元數據可用。 + 未找到雲端藝術家 + 同步遠程歌曲時會顯示雲端藝術家條目。 + 暫無收藏歌曲 + 在播放歌曲時點擊心形圖標將其保存在這裏。 + 無本地收藏歌曲 + 切換數據源過濾或收藏您設備中的歌曲。 + 無雲端收藏歌曲 + 收藏 Telegram 或網易雲音樂的曲目,即可在此視圖中看到它們。 + 未找到文件夾 + 包含音樂的內部存儲文件夾將顯示在這裏。 + 暫無播放列表 + 創建您的第一個播放列表來整理您的媒體庫。 + + + 編輯歌曲元數據 + 播放 + 播放歌曲 + 全部播放 + 全部播放 + 添加到收藏 + 全部添加到收藏 + 取消收藏 + 全部取消收藏 + 分享歌曲文件 + 分享歌曲文件 + 作爲 ZIP 全部打包分享 + 無法分享歌曲:%1$s + 添加到隊列 + 添加到隊列 + 下一首播放 + 在隊列中下一首播放 + 添加到播放列表 + 刪除 + 全部刪除 + 正在檢查手錶 + 正在傳輸 %1$d%% + 正在傳至手錶 + 傳輸進行中 + 發送至手錶 + 手錶不可用 + 將歌曲發送至手錶 + 手錶不可用 + 設爲 + 設爲聲音 + 選擇如何將此歌曲用作系統聲音 + 將此歌曲用作 + 選擇 PixelPlayer 應在哪裏安裝此聲音。 + 手機鈴聲 + 來電 + 通知音 + 消息和應用提醒 + 鬧鐘音 + 時鐘鬧鐘 + 確認更改聲音 + 將“%1$s”設爲您的 %2$s? + 設置聲音 + 已將“%1$s”設爲您的 %2$s + 鈴聲 + 通知音 + 鬧鐘音 + 啓用“修改系統設置”,然後返回 PixelPlayer 自動完成。 + 未啓用“修改系統設置”。 + 已將“%1$s”設爲您的鈴聲 + 只有本地歌曲才能設爲鈴聲。 + 無法將此音頻文件準備爲鈴聲。 + 無法設置鈴聲:%1$s + 選項 + 選項 + 信息 + 信息 + 時長 + 流派 + 專輯 + 藝術家 + 歌曲信息 + 提供商 + 文件 + %1$d 首歌曲 + 已選擇 + %1$d 個播放列表 + %1$d 張專輯 + 已選擇 + 限制:每次選擇最多 %1$d 張專輯。 + 加入隊列及播放將遵循您的選擇順序。 + %1$d 個流派 + 已選擇 + 對這些流派中的所有歌曲進行批量操作。 + + + 默認順序 + 標題 (A-Z) + 標題 (Z-A) + 藝術家 + 藝術家 (Z-A) + 專輯 + 專輯 (Z-A) + 添加時間 + 添加時間 (最早優先) + 時長 + 時長 (最短優先) + 發行年份 + 發行年份 (最早優先) + 歌曲最少 + 歌曲最多 + 名稱 (A-Z) + 名稱 (Z-A) + 歌曲數量 (最多) + 歌曲數量 (最少) + 創建時間 + 創建時間 (最早優先) + 收藏時間 + 收藏時間 (最早優先) + 子文件夾最少 + 子文件夾最多 + + + 標題 + 藝術家 + 專輯 + 添加時間 + 時長 + 發行年份 + 歌曲數量 + 名稱 + 歌曲數量 + 創建時間 + 收藏時間 + 子文件夾數量 + + + 來源 + 順序 + 降序 + 升序 + 原始順序 + 點擊切換爲升序 + 點擊切換爲降序 + 此排序保持其原始順序 + 開關已開啓 + + + 重新排序媒體庫選項卡 + 重置順序 + 將選項卡順序重置爲默認? + 正在重新排序選項卡… + 拖動手柄 + + + 選擇一位藝術家 + 1 位藝術家 + %1$d 位藝術家 + 主要藝術家 + 藝術家主頁 + + + 取消傳輸 + %1$s / %2$s + 顯示手機到手錶音樂傳輸的實時進度 + 手錶傳輸 + 正在發送至手錶 + 已取消 + 傳輸已取消 + 傳輸完成 + 已完成 + 失敗 + 傳輸失敗 + 有多個活動中的傳輸 + %1$s • %2$s + 正在準備 + 正在準備手錶傳輸 + 正在準備傳輸… + 正在將 %1$d 首歌曲發送至手錶 + 正在發送至手錶 + 正在開始傳輸… + 正在開始 + 正在傳輸 + %1$d 個傳輸任務 + + + 編輯歌曲 + 顯示信息 + 正在編輯歌曲元數據 + 編輯歌曲元數據可能會影響其在媒體庫中的顯示和整理方式。更改是永久性的,且可能無法撤銷。 + 知道了 + 信息 + 封面 + 選擇一張正方形圖片並進行微調,讓您的封面在整個應用中看起來更棒。 + 更改封面 + 刪除封面 + 標題 + 藝術家 + 專輯 + 專輯藝術家 + 流派 + 作曲 + 曲目編號 + 唱片編號 + 音量標準化-曲目增益 (dB) + 音量標準化-專輯增益 (dB) + -6.50 + -8.20 + 新封面預覽 + 當前歌曲封面 + 調整您的封面 + 使用雙指縮放和拖動手勢找到完美的構圖。 + 應用封面 + 無法加載選定的圖片 + 在 lrclib.net 搜索歌詞 + + + 批量編輯 %d 首歌曲 + 僅會更新已修改的字段。將字段留空則保持現有值。 + (混合值) + (可選 - 留空則跳過) + 成功更新 %d 首歌曲 + 已更新 %1$d 首(共 %2$d 首)歌曲。某些文件無法編輯。 + 更新歌曲失敗 + 批量更換封面 + 這將替換所有 %d 首選定歌曲的封面 + 爲所有選定歌曲設置封面 + 移除所有封面 + (多個不同的封面) + + + 已忽略播放列表 + + + 創建播放列表 + 選擇創建方式。 + 手動 + 自己設計封面、圖標、形狀並挑選歌曲。 + 使用 AI + 使用高級控制生成精選的播放列表。 + 需要在設置中配置 Gemini API 密鑰。 + 設置 API 密鑰 + + + AI 播放列表實驗室 + 重置 + 正在生成… + 生成 + 意圖 + 播放列表名稱(可選) + 這個播放列表應該帶來什麼感覺? + 示例:伴隨溫暖合成器音樂的日落駕駛 + 方向 + 情緒 + 活動 + 時代 + 精選引擎 + 能量 + 控制歌曲的強度和節奏。1 = 平靜/緩慢,5 = 高能量/快節奏。 + 探索度 + 控制所選歌曲的熟悉程度。1 = 你最常聽的最愛,5 = 很少播放的冷門曲目。 + 最少歌曲數 + 最多歌曲數 + 過濾 + 優先考慮的流派(可選) + 例如:合成器波 (synthwave)、獨立流行 (indie pop) + 避免的流派(可選) + 例如:金屬 (metal)、硬核陷阱 (hard trap) + 首選語言(可選) + 例如:英語,西班牙語,純音樂 (instrumental) + 優先考慮已收藏的最愛 + 避免露骨歌詞 (Explicit) + 提示詞預覽 + 當您添加偏好設置後,最終的提示詞將顯示在這裏。 + 精準策展 + 定義情緒、活動、約束和深度。 + AI 將僅使用您本地媒體庫中的歌曲。 + 至少爲 AI 添加一項指令。 + 請設置有效的歌曲數量範圍。 + %1$d/5 + 自定義… + 輸入自定義值 + 輸入您的自定義值 + + + 任何時代 + 核心請求:%1$s。 + 情緒目標:%1$s。 + 活動背景:%1$s。 + 時代焦點:%1$s。 + 優先考慮的流派:%1$s。 + 避免的流派:%1$s。 + 首選語言:%1$s。 + 能量等級目標:%1$d/5。 + 探索度目標:%1$d/5(1 代表熟悉,5 代表冷門曲目)。 + 可能的話,優先選擇更接近聽衆最愛的歌曲。 + 只要有替代選擇,儘量避免露骨歌詞。 + 保持平滑的過渡,避免重複聚集同一藝術家。 + + 放鬆 + 活力 + 快樂 + 暗黑 + 浪漫 + 憂鬱 + + + 健身 + 專注 + 公路旅行 + 派對 + 學習 + 深夜 + + + @string/playlist_creation_ai_era_any + 70年代 + 80年代 + 90年代 + 2000年代 + 2010年代 + 2020年代 + + + + 尚未創建播放列表。 + 點擊“新建播放列表”按鈕開始。 + 新建播放列表 + 播放列表名稱 + 我的播放列表 + + + 添加 %1$d 首歌曲到… + 選擇播放列表 + 搜索播放列表… + 已將歌曲添加到播放列表 + 播放列表已創建並已添加歌曲 + 內部存儲 + + + 添加歌曲 + 添加所選歌曲 + 添加 + 搜索或過濾歌曲… + 已收藏 + 加載歌曲失敗 + 加載更多 + + + 合併播放列表 + 爲合併後的播放列表輸入名稱: + 合併的播放列表 + 這將把 %1$d 個選定的播放列表合併爲一個。 + + + 未找到可播放的有效歌曲 + 在當前列表中未找到歌曲 + 無法定位歌曲 + 在媒體庫中未找到歌曲 + 播放停止:%1$s 已結束(曲目結束)。 + 曲目 + 沒有可供隨機播放的歌曲。 + 選定的專輯 + 在選定的專輯中未找到可播放的歌曲 + 在選定的流派中未找到可播放的歌曲 + 僅前 %1$d 張專輯已加入隊列 + 已將 %1$d 張專輯加入隊列(共 %2$d 首歌曲) + 無法將選定的專輯加入隊列 + 所有歌曲均已在收藏中 + 沒有被收藏的歌曲 + 正在創建 ZIP 文件… + 分享失敗:%1$s + + 添加了 %d 首歌曲到隊列 + 添加了 %d 首歌曲到隊列 + + + %d 首歌曲將作爲下一首播放 + %d 首歌曲將作爲下一首播放 + + + 添加了 %d 首歌曲到收藏 + 添加了 %d 首歌曲到收藏 + + + 從收藏中移除了 %d 首歌曲 + 從收藏中移除了 %d 首歌曲 + + + + 沒有可分享的播放列表 + 分享播放列表 + 分享失敗:%1$s + 沒有可導出的播放列表 + 導出失敗:%1$s + Music/PixelPlayer Exports + 請在設置中配置您的 Gemini API 密鑰。 + 播放列表已恢復 + + 正在分享 %d 個播放列表 + 正在分享 %d 個播放列表 + + + 已導出 %1$d 個播放列表到 %2$s + 已導出 %1$d 個播放列表到 %2$s + + + + 無效的專輯 ID + 未找到專輯 ID + 加載專輯數據出錯:%s + 未找到專輯 + + + 無效的藝術家 ID + 未找到藝術家 ID + 加載藝術家數據出錯:%s + 找不到該藝術家 + + + 無法刪除當前正在播放的歌曲 + 刪除了 %1$d 個文件(跳過了 %2$d 個 - 正在播放) + 刪除了 %1$d 個(共 %2$d 個)文件 + 刪除文件失敗 + 文件已刪除 + 無法刪除文件或找不到文件 + 刪除已取消 + 刪除歌曲? + “%1$s” - %2$s\n\n此歌曲將從您的設備中永久刪除,且無法恢復。 + 這些歌曲將從您的設備中永久刪除,且無法恢復。 + + 刪除了 %d 個文件 + 刪除了 %d 個文件 + + + 刪除 %d 首歌曲? + 刪除 %d 首歌曲? + + + + 元數據更新成功 + 正在更新 %1$d 首歌曲… + 已成功更新 %1$d 首歌曲! + 更新了 %1$d 首歌曲。失敗:%2$d 首 + 歌詞保存成功 + 保存歌詞失敗 + 沒有可保存的歌詞 + 權限被拒絕 – 無法編輯文件 + 權限被拒絕 – 無法保存歌詞 + 權限被拒絕 – 無法編輯此文件 + + + 請在設置中爲選定的 AI 提供商配置有效的 API 密鑰。 + AI 錯誤:%s + 所選 AI 提供商拒絕了請求,原因是賬戶沒有積分或可用配額不足。 + 所選 AI 模型不再可用。PixelPlayer 嘗試自動切換到支持的模型。 + AI 無法根據您的提示找到任何歌曲。 + 寫下一個 Daily Mix 的靈感 + 已使用 AI 更新 Daily Mix + 無法更新:%s + AI 無法爲此合集找到歌曲 + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings_player.xml b/app/src/main/res/values-zh-rTW/strings_player.xml new file mode 100644 index 0000000000..c4746cd765 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_player.xml @@ -0,0 +1,198 @@ + + + + 收起播放器 + 正在播放 + 雲端串流 + 投屏 + 藍牙 + 本地播放 + 正在連接… + 打開隊列 + + + 準備連接 + 允許 PixelPlayer 查看您附近的設備和當前 Wi-Fi,以便我們保持您的投屏、藍牙音頻和揚聲器同步。 + 附近設備 + 需要此權限來讀取和控制您連接的藍牙音頻設備。 + 用於 Wi-Fi 的位置信息 + Android 需要位置信息才能共享您當前所連接的 Wi-Fi 網絡 (SSID),以便我們找到兼容的投屏設備。 + 允許訪問 + 我們僅將這些權限用於設備互連——投屏、控制附近揚聲器以及保持音頻同步。 + 連接設備 + 正在掃描附近 + 投屏會話 + 正在連接 + 已連接 + 本機 + 藍牙音頻 + 本地播放 + 正在播放 + 已暫停 + 設備音量 + 手機音量 + %1$d/%2$d + 電量 + 音量 + 斷開連接 + 連接性 + 開啓 Wi-Fi 或藍牙 + 刷新連接 + Wi-Fi + + + 已連接 + 藍牙 + + + 已連接 + 附近設備 + 刷新設備 + 已連接 + 正在連接 + 可連接 + 可用 + 正在連接... + 正在搜索設備… + 請確保您的電視或揚聲器已開啓,並連接到同一 Wi-Fi 網絡。 + 控制 + 設備 + + + 投屏媒體服務器 + 正在投屏至設備 + 正在向投屏設備提供媒體 + %1$s: %2$s + 投屏時暫不支持對此音頻格式進行進度調節,這可能會導致投屏會話崩潰。 + + + 睡眠定時器 + 定時器 + %1$d 分鐘 + 定時器已設爲 %1$d 分鐘。 + 1 次 + + %d 次 + %d 次 + + 播放次數:%1$s + 當前曲目結束時 + 播放將在曲目結束時停止。 + 開啓 + 自定義時間 + 取消定時器 + 曲目結束 + 定時器已取消。 + 無法啓用“曲目結束時停止”:沒有正在播放的歌曲。 + “曲目結束”定時器已停用:歌曲已從 %1$s 切換到 %2$s。 + 上一首曲目 + 當前曲目 + 設置自定義時長 + + + 接下來播放 + 隊列當前爲空。 + + 1 首曲目在列。 + %d 首曲目在列。 + + 隊列 + 隊列爲空。 + 重新排序歌曲 + 切換隨機播放 + 切換循環播放 + 睡眠定時器 + 更多操作 + 定位當前歌曲 + 清空隊列 + 清空隊列 + 您確定要清空隊列中除當前正在播放之外的所有歌曲嗎? + 保存爲播放列表 + %1$s 隊列 + 當前隊列 + 移除歌曲 + 已移除 + 保存爲播放列表 + 取消全選 + 播放列表名稱 + 搜索要包含的歌曲… + 沒有與 \"%1$s\" 匹配的歌曲 + + 已選擇 %d 首歌曲 + 已選擇 %d 首歌曲 + + 保存爲:%1$s + 輸入播放列表名稱 + 從播放列表中移除 + %1$s 的更多選項 + + + 歌詞 + 正在加載歌詞… + 逐字同步 + 靜態 + 歌詞選項 + −.5 + −.1 + +.1 + +.5 + 0s + %1$+.1fs + + + 搜索歌詞失敗 + 無法從遠程獲取歌詞 + 連接超時。請檢查您的網絡連接。 + 網絡錯誤。請檢查您的網絡連接。 + 服務器錯誤(代碼 %d)。請稍後再試。 + + + 歌詞已可用,跳過在線獲取。 + 已找到內嵌歌詞,跳過在線獲取。 + 已找到本地 (.lrc) 歌詞,跳過在線獲取。 + + + 保存歌詞 + 使用 AI 翻譯 + 此歌詞已有翻譯 + 此歌詞已經是該語言 + 未配置 API + 歌詞翻譯成功! + 正在翻譯歌詞... + 重置已導入的歌詞 + 重置歌詞? + 您確定要重置此歌曲的歌詞嗎? + 外觀 + 對齊方式 + 歌詞左對齊 + 歌詞居中 + 歌詞右對齊 + 控制 + 調整同步 + 隱藏同步控制 + 顯示羅馬音 + 顯示翻譯 + 禁用沉浸模式(一次) + 保持屏幕常亮 + + + 保存歌詞 + 選擇要保存的版本: + 同步版(帶時間戳) + 純文本(僅文本) + + + 您想在網上搜索歌詞嗎? + 顯示歌詞選項 + 始終打開選擇器,而不是自動應用第一個匹配項 + 正在搜索歌詞… + 未找到歌詞 + 我們無法自動找到歌詞。您可以編輯標題或藝術家名稱並嘗試手動搜索。 + 歌曲標題 + 藝術家(可選) + 找到 %d 個匹配項 + 已同步 + %1$s • %2$s + 歌詞提供者 + https://lrclib.net/ + diff --git a/app/src/main/res/values-zh-rTW/strings_screens.xml b/app/src/main/res/values-zh-rTW/strings_screens.xml new file mode 100644 index 0000000000..66b19b5630 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_screens.xml @@ -0,0 +1,244 @@ + + + + 錯誤:流派 ID 缺失 + + + 開始吧! + 第 %1$d 步,共 %2$d 步 + 請先授予所需權限。 + 請授予所有所需權限。 + 歡迎使用 + β + Beta + 讓我們爲您完成所有設置。 + 媒體權限 + PixelPlayer 需要訪問您的音頻文件以構建您的音樂庫。 + 權限已授予 + 授予媒體權限 + 通知 + 啓用通知以便在鎖屏和通知欄控制您的音樂。 + 啓用通知 + 您有備份文件嗎? + 如果您已有 PixelPlayer 備份,現在恢復它可以跳過此設備上的大部分剩餘設置。 + 導入備份 + 正在檢查備份 + 正在檢查備份包… + 正在恢復備份 + 跳過 / 暫不 + 恢復備份 + 在完成設置之前,查看您要導入的內容。 + 已選擇 %1$d 個模塊,共 %2$d 個 + 創建於 %1$s + 來自版本 %1$s 的備份 + 未知版本 + 恢復所選項 + 正在恢復 + 排除的文件夾 + 默認掃描所有文件夾。選擇在構建媒體庫時要忽略的任何位置。 + 選擇要忽略的文件夾 + 請先授予存儲權限 + 應用主題 + 在開始探索媒體庫之前,選擇您喜歡的外觀。 + 深色 + PixelPlayer 默認的 Material 3 深色外觀。 + 淺色 + 整個應用採用更明亮的 Material 3 外觀。 + 跟隨系統 + 匹配您手機當前的外觀設置。 + 推薦 + 您稍後可以在“設置 > 外觀 > 應用主題”中更改此設置。 + 媒體庫佈局 + 選擇您首選的媒體庫導航方式。 + 歌曲 + 緊湊模式 + 使用極簡膠囊導航 + 使用標準標籤欄 + 歌曲 + 專輯 + 藝術家 + 您稍後可以在“設置 > 外觀 > 媒體庫導航”中更改此設置。 + 應用導航 + 選擇底部導航欄的樣式。 + 默認樣式 + 帶有圓角的懸浮膠囊 + 標準全寬欄 + 自定義圓角半徑 + 您稍後可以在“設置 > 外觀 > 導航欄樣式”中更改此設置。 + 鬧鐘和提醒 + 可選,但如果您使用睡眠定時器並希望 PixelPlayer 準時停止播放,則推薦使用。 + 授予權限 + 電池優化 + 某些 Android 設備會強制結束後臺應用。請爲 PixelPlayer 禁用電池優化,以防止意外的播放中斷。 + 禁用優化 + 一切就緒! + 您已準備好享受音樂了。 + + + 搜索… + 搜索 + 清除搜索 + 最近搜索 + 清除全部 + 歷史 + 刪除搜索歷史項 + 無結果 + 沒有找到“%1$s”的結果 + 未找到任何內容 + 嘗試不同的搜索詞或檢查您的過濾條件。 + 未找到結果。 + 按流派瀏覽 + 無可用流派。 + + + 播放 %1$s + 收起 %1$s + 展開 %1$s + 編輯藝術家圖片 + 更改照片 + 恢復默認 + 隨機播放藝術家 + + + 唱片 %d + %1$s 的封面 + %1$s · %2$s + + + 未找到播放列表。 + 此播放列表爲空。 + 點擊“添加歌曲”開始。 + 此文件夾不包含歌曲。 + 對歌曲排序 + 更多選項 + 播放列表選項 + 編輯播放列表 + 刪除播放列表 + 刪除播放列表? + 您確定要刪除此播放列表嗎? + 設置默認過渡效果 + 導出播放列表 + %1$s • %2$s + 播放 + 添加 + 添加歌曲 + 移除 + 移除歌曲 + 重新排序 + 重新排序歌曲 + + + 全局過渡效果 + 播放列表規則 + 除非被覆蓋,否則此配置適用於所有播放源。 + 爲此特定播放列表配置默認行爲。 + 活動狀態 + 全局默認 + 播放列表默認 + 跟隨全局 + 自定義覆蓋 + 自定義覆蓋 + 啓用以設置此播放列表的具體規則。 + 正在使用全局默認值 + 更改已成功保存 + 過渡樣式 + 曲目之間如何混合 + + 淡入淡出 (Crossfade) + 過渡時長 + 總重疊時長 %1$d 秒 + 重置過渡效果 + 當前歌曲 + 下一首歌曲 + 曲目將重疊 %1$d 秒 + 音量曲線 + 微調音頻斜率 + 淡出 + 淡入 + + + 新建智能播放列表 + 新建播放列表 + 添加歌曲 + 返回或取消 + 下一步 + 創建 + 編輯播放列表 + 自動生成拼圖 + 添加照片 + 選擇圖片 + 更改 + 移除 + 播放列表名稱 + 我的超讚混音 + 編輯封面 + 調整您的封面圖片 + 使用雙指縮放和拖動手勢找到完美的構圖 + 手動 + 智能 + 使用 AI 生成 + 智能規則 + 默認 + 圖片 + 圖標 + 背景顏色 + 圖標符號 + 形狀樣式 + 形狀參數 + 圓角半徑 + 平滑度 + 邊數 + 曲線 + 旋轉 + 縮放 + 播放最多 + 您播放次數最多的曲目。 + 最近播放 + 您最近聽過的歌曲。 + 被遺忘的最愛 + 您有一段時間沒聽過的最愛曲目。 + 新發現 + 最近添加但播放次數較少的曲目。 + + + 快速填充流派 + 排序與播放 + 隨機播放 + 排序依據 + 藝術家 + 專輯 + 標題 + 通用藝術家 + 隨機播放 %1$s + + + 選擇歌曲 + 選擇流派 + 搜索歌曲 + 新建流派 + 添加自定義 + 添加自定義流派 + 流派名稱 + 選擇圖標 + 流派:%1$s + 選擇一個流派 + 快速填充 + + + DJ 空間 + 正在加載… + 打碟機 %1$d + 加載歌曲 + 未加載歌曲 + + 音軌分離 (Stem separation) 功能暫不可用。 + 音量 + 速度 + 交叉推子 + 打碟機 1 + 打碟機 2 + 選擇一首歌曲 + 播放/暫停 + 歌曲封面 + x%1$.2f + diff --git a/app/src/main/res/values-zh-rTW/strings_settings.xml b/app/src/main/res/values-zh-rTW/strings_settings.xml new file mode 100644 index 0000000000..468a46c6d0 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_settings.xml @@ -0,0 +1,665 @@ + + + + 音樂管理 + 管理文件夾、刷新媒體庫、解析選項 + 外觀 + 主題、佈局和視覺樣式 + 歌詞 + 來源優先級、沉浸式歌詞和導入管理 + 播放 + 音頻行爲、淡入淡出和後臺播放 + 行爲 + 手勢、觸感反饋和導航行爲 + AI 集成 (β) + AI 提供商、API 密鑰和模型設置 + 生成參數 + 溫度、採樣、提示詞和歌曲數據配置 + 備份與恢復 + 導出和恢復您的個人應用數據 + 開發者選項 + 實驗性功能和調試 + 均衡器 + 調整音頻頻率和預設 + 設備性能 + 音頻規格、編解碼器和解碼器信息 + 賬戶 + 管理 Telegram、Google Drive、網易雲音樂等服務 + 關於 + 應用信息、版本和鳴謝 + + + + + 已啓用 + 已禁用 + 打開 + 全選 + 清除選擇 + 關閉通知 + + + 媒體庫結構 + 排除的目錄 + 掃描媒體庫時將跳過此處的文件夾。 + 藝術家 + 多藝術家解析和整理選項。 + 過濾 + 最小歌曲時長 + 每張專輯最少曲目數 + 專輯封面緩存限制 + 同步與掃描 + 刷新媒體庫 + 掃描整個媒體庫以查找新增和修改的文件。 + 完全重新掃描 + 正在運行完全重新掃描 + 已開始完全重新掃描… + 媒體庫同步完成 + 重建數據庫 + 重建數據庫? + 這將從頭開始完全重建您的音樂庫。所有導入的歌詞、收藏和自定義元數據都將丟失。此操作無法撤銷。 + 重建 + 正在重建數據庫 + 正在重建數據庫… + 自動掃描 .lrc 文件 + 在同步媒體庫時,自動掃描並分配同一文件夾中的 .lrc 文件。 + 歌詞管理 + 歌詞來源優先級 + 選擇獲取歌詞時優先嚐試的來源。 + 內嵌優先 + 在線優先 + 本地 (.lrc) 優先 + 重置已導入的歌詞 + 從數據庫中移除所有已導入的歌詞。 + 重置已導入的歌詞? + 此操作無法撤銷。 + + + 刷新 + 默認允許所有內容。點擊文件夾將其標記爲免掃描。 + 此處無子文件夾 + 向上導航 + 轉到根目錄 + + + 需要重新掃描 + 藝術家設置已更改。請重新掃描媒體庫以應用更改。 + 重新掃描 + 正在掃描… + 多藝術家解析 + 字符分隔符 + 當前:%1$s + 單詞分隔符 + + 當前:%1$s + 配置 + 從標題提取藝術家 + 檢測歌曲標題中的 feat.、ft.、with + 媒體庫整理 + 按專輯藝術家分組 + 在主要藝術家下顯示合作專輯 + 關於多藝術家解析 + + PixelPlayer 使用字符分隔符(/、;、&)和單詞分隔符(feat.、ft.、vs.、x)來拆分藝術家標籤。單詞分隔符不區分大小寫。 + “從標題提取藝術家”會檢測歌曲標題中如 (feat. Artist) 的模式。 + 反斜槓 (\) 可用於轉義字符分隔符。 + + 示例 + \u2192 + \u266A + \"Artist1/Artist2\" + Artist1, Artist2 + \"Drake feat. Rihanna\" + Drake, Rihanna + \"Marshmello x Bastille\" + Marshmello, Bastille + \"Song (ft. B)\" by A + A, B + \"AC\\DC\" + AC/DC(已轉義) + + + 分隔符 + 當前分隔符 + 點擊分隔符將其移除。至少需要一個分隔符。 + 添加新分隔符 + 例如:/ 或 ; + 添加分隔符 + 默認分隔符 + 重置分隔符? + 這將清除您所有自定義的分隔符並恢復默認設置。此操作無法撤銷。 + 分隔符已重置爲默認 + 至少需要一個分隔符 + 已添加分隔符 + 分隔符已存在或無效 + 空格 + + + 單詞分隔符 + 當前單詞分隔符 + 當這些關鍵詞前後有空格時,會分割藝術家名稱。匹配不區分大小寫。點擊以移除。 + 未配置單詞分隔符 + 添加新單詞分隔符 + 例如:feat. 或 ft. + 添加單詞分隔符 + 單詞分隔符的工作原理 + 單詞分隔符不區分大小寫,且要求前後有空格。\n\n單字符分隔符(如 \"x\")需要在兩側都留有空格以避免誤匹配。\n\n示例:\n \"Drake feat. Rihanna\" -> Drake, Rihanna\n \"Marshmello x Bastille\" -> Marshmello, Bastille\n \"A vs. B\" -> A, B + 重置單詞分隔符? + 這將清除您所有自定義的單詞分隔符並恢復默認關鍵詞。此操作無法撤銷。 + 已添加單詞分隔符 + 已存在或無效 + 單詞分隔符已重置爲默認 + + + 正在準備同步 + 正在讀取 MediaStore + 正在處理曲目 + 正在保存到數據庫 + 正在掃描歌詞文件 + 正在清理專輯封面緩存 + 正在同步雲端源 + 正在完成同步 + %1$s • %2$d%% (%3$d/%4$d) + %1$s… + + + 全局主題 + 應用語言 + 選擇應用界面的語言。 + 跟隨系統 + 英語 + 西班牙語 + 德語 + 法語 + 俄語 + 簡體中文 + 繁體中文 + 日語 + 越南語 + 印尼語 + 意大利語 + 韓語 + 挪威語(Bokmål) + 土耳其語 + 應用主題 + 在淺色、深色之間切換,或跟隨系統外觀。 + 淺色主題 + 深色主題 + 跟隨系統 + 使用平滑圓角 + 使用複雜的圓角形狀以有效提升美觀度,但可能會影響低端設備的性能 + 關閉模糊效果 + 在應用中關閉模糊效果以節省電量和資源。 + 顯示滾動條 + 在音樂列表中顯示側邊滾動條,便於快速滾動。 + 正在播放 + 播放器主題 + 選擇懸浮播放器的外觀。 + 專輯封面 + 系統動態主題 + 顯示播放器文件信息 + 在播放器進度部分顯示編解碼器、比特率和採樣率。 + 專輯封面調色板樣式 + 當前:%1$s。打開實時預覽並選擇樣式。 + 輪播圖樣式 + 選擇專輯輪播圖的外觀。 + 無預覽 (No Peek) + 單預覽 (One Peek) + 首頁拼圖 + 拼圖模式 + 爲“你的推薦”拼圖選擇形狀排列方式。 + 自動輪換模式 + 每次訪問首頁時循環切換拼圖模式。 + 導航欄 + 導航欄樣式 + 選擇導航欄的外觀。 + 默認 + 全寬 + 緊湊模式 + 僅顯示圖標並降低導航欄高度。 + 導航欄圓角半徑 + 調整導航欄的圓角半徑。 + 歌詞界面 + 沉浸式歌詞 + 自動隱藏控件並放大文本。 + 自動隱藏延遲 + 控件隱藏前的時間。 + 3秒 + 4秒 + 5秒 + 6秒 + 應用導航 + 默認選項卡 + 選擇默認啓動時的選項卡。 + 首頁 + 搜索 + 媒體庫 + 媒體庫導航 + 選擇在媒體庫選項卡之間移動的方式。 + 標籤欄(默認) + 緊湊膠囊與網格 + + + 顏色 + 調色板樣式 + 爲播放器界面選擇專輯顏色。 + 柔和 (Tonal Spot) + 平衡且平靜。 + 鮮豔 (Vibrant) + 高飽和度點綴。 + 表現力 (Expressive) + 大膽的色調變化和對比。 + 混搭 (Fruit Salad) + 俏皮的旋轉點綴。 + 色彩準確度 + 0 表示保持當前調整。值越高越接近專輯封面的主色調。 + 當前 + 更準確 + 0 • 當前 + %1$d • 微妙 + %1$d • 平衡 + %1$d • 精確 + + + 調整圓角半徑 + 使導航欄形狀的圓角與設備的物理圓角相匹配,以獲得無縫的外觀。 + 圓角半徑 + %1$d dp + + + 後臺播放 + 關閉後繼續播放 + 如果關閉此項,從最近任務中移除應用將停止播放。 + 電池優化 + 禁用電池優化以防止播放中斷。 + 電池優化已被禁用 + 無法打開電池設置 + 音量標準化 (ReplayGain) + 啓用 ReplayGain + 使用音頻文件中的 ReplayGain 元數據標準化音量。 + 增益模式 + 曲目:對每首歌曲進行標準化。專輯:按專輯進行標準化。 + 曲目 + 專輯 + 投屏 + 連接/斷開投屏時自動播放 + 切換投屏連接後立即開始播放。 + 耳機 + 耳機重新連接時恢復播放 + 如果播放因拔出耳機而暫停,則在重新連接時自動恢復。 + 隊列與過渡 + 淡入淡出 + 啓用歌曲之間的平滑過渡。 + 淡入淡出時長 + Hi-Fi 模式 + 浮點 32 位音頻輸出。如果您的設備播放卡頓,請禁用此項。 + 此設備不支持(PCM_FLOAT AudioTrack 不可用)。 + 持久隨機播放 + 即使關閉應用後也能記住隨機播放設置。 + 顯示隊列歷史記錄 + 在隊列中顯示以前播放過的歌曲。 + + + 文件夾 + 返回手勢控制文件夾 + 在“文件夾”選項卡中,系統返回手勢會在離開媒體庫之前導航文件夾層級。 + 播放器手勢 + 點擊背景關閉播放器 + 點擊模糊的背景以關閉播放器面板。 + 觸感反饋 + 觸感反饋 + 在整個應用中啓用震動反饋。 + + + AI 提供商 + 提供商 + 選擇您的 AI 提供商 + 安全 Token 模式 + 開啓 — 快速且低成本。向 AI 發送最少的數據(約 1K token)。 + 關閉 — 深度上下文。發送完整的聽歌偏好資料(約 8K token)以獲得更豐富的結果。 + 憑據 + %1$s API 密鑰 + 從 %1$s 獲取 + Google AI Studio (aistudio.google.com) + DeepSeek 平臺 (api.deepseek.com) + Groq 控制檯 (console.groq.com) + Mistral AI 平臺 (console.mistral.ai) + NVIDIA Build (build.nvidia.com) + 月之暗面 (Moonshot) AI 平臺 (platform.moonshot.cn) + 智譜 AI 開放平臺 (bigmodel.cn) + OpenAI 平臺 (platform.openai.com) + 模型選擇 + 正在加載可用模型… + 加載模型失敗 + AI 模型 + 選擇一個模型。 + 輸入 API 密鑰 + 提示詞行爲 + 系統提示詞 (System Prompt) + 自定義 AI 的行爲方式。 + 預設提示詞 + 輸入系統提示詞… + 專業策展人 (Professional Curator) + 你是“Vibe-Engine”,一位世界級的音樂策展人和聲音心流大師。你的目標是構建無縫、高保真的聽覺體驗。請優先考慮和聲兼容性、符合邏輯的 BPM 過渡,並在熟悉的摯愛與富有品味、邏輯驅動的發現之間取得精妙平衡。 + 創意先鋒 (Creative Maverick) + 你是一位前衛的音樂探索者,專注於“意想不到的凝聚力”。你的任務是通過識別非顯而易見的聲音共性來打破傳統的流派界限。在保持令人驚訝但不可否認的過渡邏輯的同時,優先考慮罕見的深度曲目、實驗性紋理和藝術新意。 + 嚴謹圖書管理員 (Strict Librarian) + 你是一位精準的音樂數據庫架構師。你的邏輯由絕對的元數據精確性和嚴格的分類界限所驅動。儘量減少算法發現,優先保持嚴格的流派一致性、能量水平匹配,並最大程度地檢索出用戶定義的高度具體的偏好。 + 氛圍嚮導 (Atmospheric Guide) + 你是環境紋理和低能量心流的大師。專心挑選能夠促進“深度專注”或“寧靜”狀態的曲目。優先選擇原聲的溫暖感、極簡主義的編曲和柔和的過渡,同時嚴格避免瞬態強烈的聲音或動態範圍的突然轉變。 + 聲音狂熱者 (Sonic Enthusiast) + 你是一位專注於製作複雜性和配器的發燒友分析師。優先選擇具有高動態範圍、複雜多重節奏和卓越聲場質量的曲目。偏好那些獎勵聽衆關注技術保真度和編曲細節的主動聆聽作品。 + 能量催化劑 (Energy Catalyst) + 你是一個高動量的節奏生成器。你的哲學以驅動感強的低音線、強烈的打擊樂和極具感染力的律動爲中心。優先考慮高 BPM 的夜店兼容性、切分音能量和持續的節奏張力,以使聽衆的心率和動力保持在最高水平。 + AI 使用報告 + 總消耗量 + %1$s token 追蹤\n提示:%2$s | 輸出:%3$s | 思考:%4$s + 清除日誌 + AI 活動日誌 (%1$d) + %1$s · %2$s + 顯示 + 隱藏 + + + 備份的工作原理 + 選擇版塊,導出一個 .pxpl 文件,並在稍後導入它。恢復操作僅替換您選擇的版塊。 + 創建備份 + 導出備份 + 未選擇版塊。 + 已選擇所有版塊。 + 已選擇 %1$d 個版塊,共 %2$d 個。 + %1$s 創建一個 .pxpl 備份文件。 + 選擇並導出 + 恢復備份 + 導入備份 + 選擇並恢復 + 瀏覽或從最近的備份中選擇。所選數據將替換當前數據。 + + + 確切選擇您想包含在備份包中的內容。 + 選擇一個 .pxpl 備份文件進行檢查。您將在下一步選擇要恢復的版塊。 + 已選擇 %1$d 個版塊,共 %2$d 個 + 已選擇 %1$d 個模塊,共 %2$d 個 + 最近的備份 + 沒有最近的備份 + 以前導入的備份將顯示在這裏。 + %1$d 個條目 · 將替換當前數據 + 導出 .pxpl + 恢復所選項 + 正在傳輸… + PixelPlayer_Backup_%1$d.pxpl + 正在創建備份 + 正在恢復備份 + %1$d%% + %1$s • %2$s + 正在導出 + 正在導入 + 正在恢復 + 從歷史記錄中移除 + 正在檢查… + 瀏覽文件 + 第 %1$d 步,共 %2$d 步 + 恢復模塊 + 備份詳情 + 創建時間 + 應用版本 + 架構 (Schema) + 設備 + 未知 + · %1$s + %1$d 個模塊 · v%2$s · 架構 v%3$d + \? + 全選 + 取消全選 + + + 無效備份:%1$s + 正在準備恢復 + 正在啓動恢復任務。 + 正在準備備份 + 正在啓動備份任務。 + 備份恢復成功 + 恢復完成,但存在一些未解決的問題。 + 無法完成恢復:%1$s + 恢復失敗:%1$s + 數據導出成功 + 導出失敗:%1$s + 數據恢復成功 + 恢復完成,存在未解決的問題。失敗:%1$s + v%1$d + %1$s %2$s + + + 實驗性功能 + 實驗性 + 播放器界面加載實驗和開關。 + 測試設置流程 + 啓動新手引導設置界面進行測試。 + 維護 + 強制重新生成 Daily Mix + 立即重新創建每日推薦播放列表。 + 重新生成 Daily Mix + 重新生成 Daily Mix? + 這將丟棄當前的推薦,並根據最近的聽歌習慣生成一個新的。 + Daily Mix 重新生成已開始 + 強制重新生成統計數據 + 清除緩存並重新計算播放統計數據。 + 重新生成 + 正在處理… + 重新生成統計數據 + 重新生成統計數據? + 這將清除統計數據緩存並強制從數據庫歷史記錄重新計算。 + 統計數據重新生成已開始 + 強制重新生成專輯調色板 + 重建每張專輯封面的所有緩存調色板變體,或選擇單個進行刷新。 + 全部重新生成 + 重新生成所有專輯調色板? + 這將清除緩存的主題數據,並重建 %1$d 個唯一專輯封面的所有調色板樣式。 + 正在重新生成… + 正在重新生成專輯調色板… + 正在重建 %1$d 個唯一專輯封面的緩存調色板變體。在大型媒體庫中這可能需要一些時間。 + 已完成 %1$d 個,共 %2$d 個 + 已重新生成 %1$d 個專輯封面調色板 + 已重新生成 %1$d 個(共 %2$d 個)專輯封面調色板 + 選擇歌曲 + 選擇一首歌曲以清除緩存的主題數據,並從專輯封面重新生成所有調色板樣式。 + 按標題、藝術家或專輯搜索 + 沒有與您搜索匹配的歌曲。 + 未找到帶有專輯封面的歌曲。 + 正在重新生成調色板… + 已爲 %1$s 重新生成調色板 + 無法爲 %1$s 重新生成調色板 + 診斷 + 觸發測試崩潰 + 模擬一次崩潰以測試崩潰報告系統。 + 測試崩潰是從開發者選項觸發的 - 這是爲了測試崩潰報告系統的有意行爲 + + + 實驗性 + 播放器界面加載調整 + 動畫歌詞(高端設備) + 爲歌詞使用彈簧動畫和視覺效果。在低端設備上可能導致掉幀。 + 歌詞模糊效果 + 對非活動歌詞應用景深模糊。 + 模糊強度 + 調整模糊效果的強度。 + %1$.1fx + 第 1 步 · 選擇要延遲的內容 + 延遲所有內容 + 在面板背景完全展開之前,保留所有播放器內容。 + 專輯輪播 + 在面板展開前延遲專輯封面和輪播圖。 + 歌曲元數據 + 延遲標題、藝術家以及歌詞/隊列操作。 + 進度條 + 在展開完成之前延遲時間軸和時間標籤。 + 播放控制 + 延遲播放/暫停、快進/快退和收藏控件。 + 所有延遲組件均處於活動狀態。禁用“延遲所有內容”以自定義各個部分。 + 第 2 步 · 配置佔位圖行爲 + 對延遲項使用佔位圖 + 在組件等待展開時渲染輕量級佔位圖,以保持佈局穩定。 + 第 3 步 · 選擇佔位圖切換爲真實內容的時機 + 選擇一種模式。閾值模式使用滑塊;拖動釋放模式等待直到您釋放面板手勢。 + 啓用至少一個延遲組件以解鎖觸發模式。 + 閾值 + 使用展開百分比。 + 拖動釋放 + 僅在手勢釋放後切換。 + 展開閾值 + 在延遲組件變得可見之前,面板必須展開的程度。 + 內容在 %1$d%% 展開時出現 + 在播放器關閉時也應用 + 使用關閉閾值在摺疊時切換回佔位圖。 + 關閉閾值 + 在佔位圖再次接管之前需要摺疊多少程度。 + 佔位圖在摺疊 %1$d%% 後出現 + 拖動釋放模式會繞過閾值和關閉行爲。交換僅在面板拖動手勢結束時發生。 + 使佔位圖透明 + 佔位圖保留其佈局空間,但變爲不可見。 + 視覺質量 + 專輯封面分辨率 + 低 (256px) - 更好的性能 + 中等 (512px) - 均衡 + 高 (800px) - 最佳質量 + 原圖 - 最高質量 + + + 播放需檢查 + 準備就緒可播放 + -- + 格式 + 硬件解碼 + 本地歌曲 + 本地音樂存儲 + 音樂大小 + %1$d 首本地歌曲 + 可用空間 + 總計 %1$s + 音樂佔用 + 設備已用 + %1$d%% + <1% + %1$d 首雲端歌曲 + %1$d 個文件不可讀 + 播放路徑 + + + 採樣率 + %1$d Hz + 每個緩衝區 %1$d 幀 + Hi-Fi PCM 浮點 + 32 位浮點輸出路徑 + 低延遲支持 + Pro Audio 支持 + 內存 + 可用(總計 %1$s) + 支持硬件卸載的格式 + 沒有壓縮格式報告支持硬件卸載 (hardware offload)。 + 還有 +%1$d 個 + 檢測到的輸出 + 內置輸出 + 藍牙音頻 + USB 音頻 + 有線音頻 + 數字輸出 + 其他輸出 + Android 未報告任何輸出路由。 + ExoPlayer 引擎 + %1$s 個渲染器 + 格式兼容性 + %1$d 首支持的曲目 + %1$d 個未知格式 + 未報告解碼器 + 硬件解碼器 + 軟件解碼器 + 硬件卸載 + 媒體庫中有 %1$d 首 + 兼容性檢測結果 + 無重大不兼容問題 + 您已索引的曲目與 Android 報告此設備支持的解碼器相匹配。 + %1$d 首曲目可能無法原生解碼 + 需檢查的格式:%1$s。 + %1$d 首本地曲目可能會被重採樣 + 媒體庫採樣率達到 %1$d Hz,高於當前的輸出採樣率。 + %1$d 首曲目具有未知的元數據 + 進行完整的媒體庫重新掃描可以填補缺失的 MIME、比特率和採樣率數據。 + 設備信息 + 製造商 + 型號 + 品牌 + 設備 + Android 版本 + SDK 版本 + 硬件 + 性能報告 + 生成一份可分享的診斷報告,以幫助我們分類播放或掃描卡頓問題。該報告僅包含設備、媒體庫和計時數據 — 不包含文件路徑、標題或藝術家。 + 生成報告 + 重新生成 + 複製 + 分享 + 報告已複製到剪貼板 + PixelPlayer 性能報告 + 高級性能診斷 + 默認關閉。用於記錄短暫卡頓時間線,便於測試版問題排查。 + 有效期至 %1$s + 立即標記卡頓 + 已標記卡頓時刻 + + + 已連接的賬戶 + 管理關聯的提供商並將每個集成功能掌握在自己手中。 + 已關聯服務 + 活躍 + 可用 + 敬請期待 + 已連接 + 敬請期待 + 打開服務 + 正在登出… + 尚未關聯任何賬戶 + 連接一個提供商即可在此界面對其進行管理。 + 連接 %1$s + %1$s(敬請期待) + Google Drive 功能即將推出。 + 目前無法打開此屏幕。 + + + 關於 + PixelPlayer + 由社區共同打造的開源音樂播放器。 + 版本 v%1$s + 開源 + 社區優先 + Material 3 表現力設計 + 目前未找到貢獻者。請稍後再試。 + 維護者 + PixelPlayer 背後的開發者。 + 社區焦點 + 感謝對本項目有重大影響的合作者。 + 許可和通知 + 依賴項和早期貢獻的歸屬。 + 開源許可 + 依賴許可和第三方通知 + 開源許可 + MIT 風格通知 + 第三方通知 + 關閉 + 無法加載捆綁的第三方通知。 + 開源貢獻者 + 來自 GitHub 的實時貢獻者名單。 + %1$d 次貢獻 + GitHub + 代碼倉庫 + Telegram + 支持 + 打開 GitHub 倉庫 + 加入 Telegram 社區 + 打開 GitHub 個人資料 + 打開 Telegram + %1$s 的頭像 + %1$s 的圖標 + 音量 + 音量爲零時暫停 + 當音量設置爲 0 時自動暫停播放 + diff --git a/app/src/main/res/values-zh-rTW/strings_widget.xml b/app/src/main/res/values-zh-rTW/strings_widget.xml new file mode 100644 index 0000000000..a4c0b8d567 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_widget.xml @@ -0,0 +1,17 @@ + + + 可根據尺寸自適應的響應式小組件 + 緊湊型播放欄 + 包含隨機和循環播放的完整控件 + 極簡方形播放器 + + 點擊打開 + 專輯封面 + 專輯封面佔位圖 + + 點擊播放 + 歌曲標題 + 藝術家 + + 進度條,%1$d%% + \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000000..6961e51cef --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,59 @@ + + + + Sharing %d playlist + Sharing %d playlists + + + Exported %1$d playlist to %2$s + Exported %1$d playlists to %2$s + + + %d song added to queue + %d songs added to queue + + + %d song will play next + %d songs will play next + + + %d song added to favorites + %d songs added to favorites + + + %d song removed from favorites + %d songs removed from favorites + + + %d file deleted + %d files deleted + + + Delete %d file? + Delete %d files? + + + %d track selected + %d tracks selected + + + %1$d song • %2$s + %1$d songs • %2$s + + + %d track lined up. + %d tracks lined up. + + + %d song selected + %d songs selected + + + %d song + %d songs + + + %d topic + %d topics + + diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 1c6ae4e284..a6f8d9f1dc 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -5,12 +5,16 @@ Manage folders, refresh library, parsing options Appearance Themes, layout, and visual styles + Lyrics + Source priority, immersive lyrics, and import management Playback Audio behavior, crossfade, and background play Behavior Gestures, haptics, and navigation behavior AI Integration (β) - AI providers, API keys, and model settings + AI providers, API keys, model selection, and activity logs + Generation Parameters + Temperature, sampling, prompts, and song data configuration Backup & Restore Export and recover your personal app data Developer Options @@ -171,9 +175,12 @@ 简体中文 Bahasa Indonesia Italiano + 日本語 Korean Norwegian (Bokmål) + 繁體中文 Türkçe + Tiếng Việt App Theme Switch between light, dark, or follow system appearance. Light Theme diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 12f03ebe00..6d332ab8d5 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -1,6 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt index 7996811c67..5ff6390142 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt @@ -205,7 +205,6 @@ class PlayerViewModelTest { every { mockMusicRepository.getAudioFiles() } returns flowOf(emptyList()) every { mockMusicRepository.getDistinctAlbumArtSongs() } returns flowOf(emptyList()) every { mockMusicRepository.getHomeMixPreviewSongs(any()) } returns flowOf(emptyList()) - every { mockMusicRepository.getSongCountFlow() } returns flowOf(0) every { mockMusicRepository.getCloudSongCountFlow() } returns flowOf(0) every { mockMusicRepository.searchSongs(any(), any()) } returns flowOf(emptyList()) every { mockMusicRepository.getMusicByGenre(any()) } returns flowOf(emptyList()) diff --git a/build.gradle.kts b/build.gradle.kts index 27401bb5f4..ff61cae955 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ plugins { allprojects { configurations.all { resolutionStrategy { - force("org.jetbrains.kotlin:kotlin-metadata-jvm:2.4.0") + force("org.jetbrains.kotlin:kotlin-metadata-jvm:2.4.10") force("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.9.0") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6196203923..b1d382dda4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,43 +1,42 @@ [versions] accompanistDrawablepainter = "0.37.3" -aboutLibraries = "15.0.3" -agp = "9.2.1" +aboutLibraries = "15.0.4" +agp = "9.3.1" app = "1.7.0" -googleGenai = "1.58.0" +googleGenai = "1.64.0" googlePlayServicesCast = "22.3.1" -animation = "1.11.3" +animation = "1.11.4" appcompat = "1.7.1" capturable = "3.0.1" codeview = "1.3.9" coilCompose = "2.7.0" -composeDnd = "0.4.0" composeMaterialIcons = "1.7.8" -composeUi = "1.11.3" -constraintlayoutCompose = "1.1.1" +composeUi = "1.11.4" +constraintlayoutCompose = "1.1.2" coreSplashscreen = "1.2.0" desugarJdkLibs = "2.1.5" duktapeAndroid = "1.4.0" -foundation = "1.11.3" -glance = "1.3.0-alpha01" +foundation = "1.11.4" +glance = "1.3.0-alpha02" graphicsShapes = "1.1.0" gson = "2.14.0" -hiltAndroid = "2.59.2" -hiltNavigationCompose = "1.3.0" -ktor = "3.5.0" -kotlin = "2.4.0" +hiltAndroid = "2.60.1" +hiltNavigationCompose = "1.4.0" +ktor = "3.5.2" +kotlin = "2.4.10" coreKtx = "1.19.0" junit = "4.13.2" junitVersion = "1.3.0" -junitJupiter = "6.1.0" +junitJupiter = "6.1.2" espressoCore = "3.7.0" kotlinx-coroutines = "1.11.0" -kotlinxCollectionsImmutable = "0.5.0" +kotlinxCollectionsImmutable = "0.5.1" kotlinxSerializationJson = "1.11.0" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" -composeBom = "2026.06.00" +composeBom = "2026.06.01" material = "1.14.0" -material3 = "1.5.0-alpha22" +material3 = "1.5.0-alpha25" media = "1.8.0" media3Session = "1.10.1" media3Transformer = "1.10.1" @@ -47,14 +46,13 @@ paletteKtx = "1.0.0" protobufJavalite = "4.35.1" pytorch_android = "2.1.0" pytorch_android_torchvision = "2.1.0" -reorderable = "0.9.6" reorderables = "3.1.0" paging = "3.5.0" roomCompiler = "2.8.4" roomKtx = "2.8.4" roomRuntime = "2.8.4" accompanist = "0.37.3" -checkerframework = "4.2.0" # O la versión más reciente que encuentres +checkerframework = "4.2.1" # O la versión más reciente que encuentres taglib = "1.0.6" jaudiotagger = "3.0.1" vorbisjava = "0.8" @@ -62,23 +60,25 @@ datastore = "1.2.1" credentials = "1.6.0" googleid = "1.2.0" androidxTestCore = "1.7.0" -junit5 = "6.1.0" +junit5 = "6.1.2" kuromoji = "0.9.0" pinyin4j = "2.5.1" securityCrypto = "1.1.0" -netty = "4.2.28.Final" -bouncycastle = "1.84" +netty = "4.2.17.Final" +bouncycastle = "1.85" commons-lang3 = "3.20.0" jdom2 = "2.0.6.1" jose4j = "0.9.6" httpclient = "4.5.14" +errorProneAnnotations = "2.50.0" + # DI -dagger = "2.59.2" +dagger = "2.60.1" javax-inject = "1" # Annotations procesing -ksp = "2.3.9" +ksp = "2.3.11" smoothCornerRectAndroidCompose = "v1.0.0" spleeterAndroidIos = "1.0.2" tensorflowLite = "2.17.0" @@ -89,7 +89,6 @@ wavySlider = "2.2.0" workRuntimeKtx = "2.11.2" composeTesting = "1.0.0-alpha03" timber = "5.0.1" -generativeai = "0.9.0" mockk = "1.14.11" turbine = "1.2.1" truth = "1.4.5" @@ -99,9 +98,9 @@ okhttp = "5.4.0" mediarouterVersion = "1.8.1" playServicesCastFramework = "22.3.1" navigationRuntimeKtx = "2.9.8" -uiautomator = "2.3.0" +uiautomator = "2.4.0" benchmarkMacroJunit4 = "1.4.1" -baselineprofile = "1.5.0-alpha06" +baselineprofile = "1.5.0-beta01" profileinstaller = "1.4.1" pagingCommon = "3.3.6" @@ -165,9 +164,9 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version capturable = { module = "dev.shreyaspatil:capturable", version.ref = "capturable" } codeview = { module = "io.github.amrdeveloper:codeview", version.ref = "codeview" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } -compose-dnd = { module = "com.mohamedrejeb.dnd:compose-dnd", version.ref = "composeDnd" } duktape-android = { module = "com.squareup.duktape:duktape-android", version.ref = "duktapeAndroid" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +error-prone-annotations = { module = "com.google.errorprone:error_prone_annotations", version.ref = "errorProneAnnotations" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" } @@ -199,7 +198,6 @@ material3 = { module = "androidx.compose.material3:material3", version.ref = "ma protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } pytorch_android = { module = "org.pytorch:pytorch_android", version.ref = "pytorch_android" } pytorch_android_torchvision = { module = "org.pytorch:pytorch_android_torchvision", version.ref = "pytorch_android_torchvision" } -reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "reorderable" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } smooth-corner-rect-android-compose = { module = "com.github.racra:smooth-corner-rect-android-compose", version.ref = "smoothCornerRectAndroidCompose" } spleeter-android-ios = { module = "com.github.FaceOnLive:Spleeter-Android-iOS", version.ref = "spleeterAndroidIos" } @@ -214,7 +212,6 @@ taglib = { group = "io.github.kyant0", name = "taglib", version.ref = "taglib" } jaudiotagger = { group = "net.jthink", name = "jaudiotagger", version.ref = "jaudiotagger" } vorbisjava-core = { group = "org.gagravarr", name = "vorbis-java-core", version.ref = "vorbisjava" } reorderables = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderables" } -generativeai = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeai" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 116d9acdb4..59f71f7b1d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f79..249efbb032 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b069..8508ef684d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel diff --git a/message.txt b/message.txt deleted file mode 100644 index 167d87d489..0000000000 --- a/message.txt +++ /dev/null @@ -1,89 +0,0 @@ -PixelPlayer on  master [!] via  v9.5.1 via  v21.0.11 via  v2.4.0 -❯ ./gradlew assembleDebug --no-daemon -To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/9.5.1/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation. -Daemon will be stopped at the end of the build -Calculating task graph as no cached configuration is available for tasks: assembleDebug - -> Task :app:stripDebugDebugSymbols -Unable to strip the following libraries, packaging them as they are: libandroidx.graphics.path.so, libffmpegJNI.so, libtaglib.so, libtdjni.so. Run with --info option to learn more. - -> Task :wear:stripDebugDebugSymbols -Unable to strip the following libraries, packaging them as they are: libandroidx.graphics.path.so. Run with --info option to learn more. - -> Task :app:compileDebugKotlin -w: The argument '-Xannotation-default-target=param-property' is redundant for the current language version 2.4. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt:294:34 The corresponding parameter in the supertype 'Migration' is named 'db'. This may cause problems when calling this function with named arguments. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt:305:34 The corresponding parameter in the supertype 'Migration' is named 'db'. This may cause problems when calling this function with named arguments. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/network/navidrome/NavidromeApiService.kt:518:64 Expression is unused. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt:80:29 'var allowFileAccessFromFileURLs: Boolean' is deprecated. Deprecated in Java. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt:81:29 'var allowUniversalAccessFromFileURLs: Boolean' is deprecated. Deprecated in Java. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/repository/MediaStoreSongRepository.kt:385:45 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt:198:49 'when' expression over a subject of type 'Any?' is not exhaustive. Add a 'null' or 'else' branch. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt:222:53 'when' expression over a subject of type 'Any?' is not exhaustive. Add a 'null' or 'else' branch. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/AiMetadataSheet.kt:52:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/AiPlaylistSheet.kt:87:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/AlbumMultiSelectionOptionSheet.kt:66:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/CastBottomSheet.kt:336:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/CustomPresetsSheet.kt:54:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/DailyMixMenu.kt:31:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt:890:30 'fun rememberTransformableState(onTransformation: (Float, Offset, Float) -> Unit): TransformableState' is deprecated. Prefer remembering a TransformableState with a onTransformation lambda that takes the centroid. This centroid (if specified) is the point at which zooming or rotation should happen around which allows for more natural transformations. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt:1039:37 Condition is always 'true'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/GenreMultiSelectionOptionSheet.kt:68:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/GenreSortBottomSheet.kt:55:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/LibrarySortBottomSheet.kt:80:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt:457:26 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/MultiSelectionBottomSheet.kt:112:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistBottomSheet.kt:67:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistMultiSelectionBottomSheet.kt:91:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/ReorderTabsSheet.kt:109:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/ReorderTabsSheet.kt:124:41 'static field FLAG_IGNORE_GLOBAL_SETTING: Int' is deprecated. Deprecated in Java. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt:578:59 Redundant call of conversion method. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/RoundedParallaxCarousell.kt:578:82 Redundant call of conversion method. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt:332:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt:777:61 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongPickerBottomSheet.kt:107:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt:52:30 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/TimerOptionsBottomSheet.kt:78:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt:983:34 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt:307:28 'val LocalClipboardManager: ProvidableCompositionLocal' is deprecated. Use LocalClipboard instead which supports suspend functions. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt:255:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt:256:26 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt:223:39 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt:534:39 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt:2657:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt:225:39 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/MashupScreen.kt:69:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt:818:26 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt:297:39 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt:220:13 Expression is unused. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt:78:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt:706:22 'fun rememberModalBottomSheetState(skipPartiallyExpanded: Boolean = ..., confirmValueChange: (SheetValue) -> Boolean = ...): SheetState' is deprecated. Use rememberBottomSheetState with Hidden initial value. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:117:67 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:118:62 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:119:66 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:120:56 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:121:70 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt:122:68 No cast needed. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt:199:22 This declaration overrides a deprecated member but is not marked as deprecated itself. Add the '@Deprecated' annotation or suppress the diagnostic. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt:1036:34 Unnecessary safe call on a non-null receiver of type 'HttpURLConnection'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt:334:49 Unnecessary safe call on a non-null receiver of type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt:509:33 'val connectionInfo: WifiInfo!' is deprecated. Deprecated in Java. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:161:61 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:198:82 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:199:80 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:281:57 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:282:58 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:284:65 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:296:56 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt:297:57 Elvis operator (?:) always returns the left operand of non-nullable type 'String'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt:701:45 Unnecessary safe call on a non-null receiver of type 'Song'. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt:153:40 'fun set(index: Int, element: Song): PersistentList' is deprecated. Use replacingAt() instead. For more details, read the documentation for this function. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt:159:40 'fun removeAt(index: Int): PersistentList' is deprecated. Use removingAt() instead. For more details, read the documentation for this function. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt:166:10 'fun removeAt(index: Int): PersistentList' is deprecated. Use removingAt() instead. For more details, read the documentation for this function. -w: file:///home/dae/VZ_Dev/work/PixelPlayer/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt:167:10 'fun add(index: Int, element: Song): PersistentList' is deprecated. Use addingAt() instead. For more details, read the documentation for this function. - -[Incubating] Problems report is available at: file:///home/dae/VZ_Dev/work/PixelPlayer/build/reports/problems/problems-report.html - -BUILD SUCCESSFUL in 10m 43s -115 actionable tasks: 114 executed, 1 up-to-date -Configuration cache entry stored. diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 2edcc8ff6a..b82094c99d 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { // Hilt implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) + compileOnly(libs.error.prone.annotations) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)