From 76aa05ca2ddef869258435a1a1f71f48217240aa Mon Sep 17 00:00:00 2001 From: Amonoman Date: Mon, 22 Jun 2026 18:57:21 +0200 Subject: [PATCH 01/21] feat(cast): add AC4 audio codec detection for ISO BMFF containers Support detecting AC4 audio (used in iOS/MOV containers) in IsoBmffAudioCodecDetector, with corresponding unit tests. --- .../data/service/cast/IsoBmffAudioCodecDetector.kt | 1 + .../data/service/cast/IsoBmffAudioCodecDetectorTest.kt | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt index c12453a7f..2033f4131 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetector.kt @@ -87,6 +87,7 @@ internal object IsoBmffAudioCodecDetector { "mp4a" -> "audio/mp4a-latm" "ac-3" -> "audio/ac3" "ec-3" -> "audio/eac3" + "ac-4" -> "audio/ac4" "flac" -> "audio/flac" "opus" -> "audio/opus" else -> null diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt index 85fad217d..4978196e7 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/cast/IsoBmffAudioCodecDetectorTest.kt @@ -23,6 +23,15 @@ class IsoBmffAudioCodecDetectorTest { assertThat(codec).isEqualTo("audio/mp4a-latm") } + @Test + fun detectAudioCodec_returnsAc4SampleEntry() { + val bytes = mp4WithTracks(audioTrack("ac-4")) + + val codec = IsoBmffAudioCodecDetector.detectAudioCodec(bytes) + + assertThat(codec).isEqualTo("audio/ac4") + } + @Test fun detectAudioCodec_skipsVideoTracks() { val bytes = mp4WithTracks( From d498b37ba3570394333d53c647ff6331e6296fa6 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 26 Jun 2026 13:29:02 +0200 Subject: [PATCH 02/21] perf(sync): parallelize Telegram metadata refinement in SyncWorker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram sync was reading each song's file (file.exists() + ID3 tag parse via AudioMetadataReader) one at a time in a sequential forEach, inside syncTelegramData's chunk loop. For large channels (~100k songs) this serial per-song file IO was the dominant cost, taking on the order of 10 minutes. Extract that IO/CPU-bound step and run it concurrently per chunk using the same Semaphore + async/awaitAll pattern already used elsewhere in this file (see the MediaStore processing phase), bounded by a new TELEGRAM_METADATA_READ_CONCURRENCY = 8 to avoid saturating disk IO. The sequential artist/album dedup maps that follow remain untouched, since they mutate shared per-chunk state and aren't safe to parallelize as-is. Also drops a redundant resolveAlbumArtUri() call that was happening twice per song when a local file was found (value doesn't change between the two call sites). No behavior change to the resulting song/album/artist data — only the ordering/concurrency of how it's computed. Co-Authored-By: Claude --- .../pixelplay/data/worker/SyncWorker.kt | 177 +++++++++++++----- 1 file changed, 130 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 46ed85698..7d9540f6d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -620,9 +620,7 @@ constructor( val representativeAlbumArt = songsInAlbum.firstNotNullOfOrNull { it.albumArtUriString } val determinedAlbumArtist = chooseAlbumDisplayArtist( songs = songsInAlbum, - preferAlbumArtist = groupByAlbumArtist, - artistDelimiters = artistDelimiters, - wordDelimiters = wordDelimiters + preferAlbumArtist = groupByAlbumArtist ) val determinedAlbumArtistId = resolveAlbumDisplayArtistId( displayArtist = determinedAlbumArtist, @@ -1223,6 +1221,11 @@ constructor( // Number of Telegram songs processed per DB flush. Keeps peak memory bounded // regardless of channel size (e.g. 65k songs → ~130 flushes of 500 each). private const val TELEGRAM_SYNC_CHUNK_SIZE = 500 + // Bounded parallelism for the per-song file.exists()+tag-read step within a chunk. + // This was previously fully sequential, which is what made very large Telegram + // channels (e.g. 100k songs) take on the order of 10 minutes to sync. Kept modest + // (same order as the MediaStore processing semaphore) to avoid saturating disk IO. + private const val TELEGRAM_METADATA_READ_CONCURRENCY = 8 private const val NETEASE_SONG_ID_OFFSET = 3_000_000_000_000L private const val NETEASE_ALBUM_ID_OFFSET = 4_000_000_000_000L @@ -1319,6 +1322,30 @@ constructor( } } + /** + * Result of refining a single Telegram song's metadata against its local file (ID3 tags), + * if one exists on disk. Computed in [refineTelegramSongMetadata], which is the part of + * [syncTelegramData]'s per-song work that's safe to parallelize (pure file IO + parsing, + * no shared mutable state). + */ + private data class RefinedTelegramMetadata( + val channelName: String, + val title: String, + val artistName: String, + val albumName: String, + val albumArtist: String, + val dateAdded: Long, + val year: Int, + val trackNumber: Int, + val discNumber: Int?, + val genre: String?, + val lyrics: String?, + val duration: Long, + val bitrate: Int?, + val sampleRate: Int?, + val albumArtUri: String? + ) + // Logic to sync Telegram songs into main DB with Unified Library Support. // // Memory safety: songs are processed and flushed to the DB in chunks of @@ -1358,6 +1385,12 @@ constructor( val albumSongCounts = mutableMapOf() var totalSynced = 0 + // Bounds concurrent file reads during metadata refinement below. This is the + // expensive part of the loop (file.exists() + tag parsing per song), so it's the + // only part run in parallel; the artist/album dedup maps that follow are mutated + // from a single thread per chunk and stay sequential, same as before. + val metadataReadSemaphore = Semaphore(TELEGRAM_METADATA_READ_CONCURRENCY) + telegramSongs.chunked(TELEGRAM_SYNC_CHUNK_SIZE).forEach { chunk -> // Per-chunk collections — allocated, used, then released each iteration. val songsToInsert = ArrayList(chunk.size) @@ -1365,54 +1398,104 @@ constructor( val albumsToInsert = mutableMapOf() val crossRefsToInsert = mutableListOf() - chunk.forEach { tSong -> - val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream" + // 2. Metadata Refinement (ID3 for Downloaded Files) — done first and in + // parallel for the whole chunk, since each song's file read is independent + // IO/CPU work. Was previously sequential, one file at a time; with 100k+ + // songs that serial file.exists()+tag-parse cost is what made large Telegram + // libraries take ~10 minutes to sync. The merge logic that follows (artist / + // album dedup maps) stays sequential, same as before — only the per-song file + // read is parallelized here. + val refinedChunk = coroutineScope { + chunk.map { tSong -> + async { + metadataReadSemaphore.withPermit { + val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream" + + var realTitle = tSong.title + var realArtistName = tSong.artist + var realAlbumName = channelName + val realDateAdded = tSong.dateAdded + var realYear = 0 + var realTrackNumber = 0 + var realDiscNumber: Int? = null + var realAlbumArtist = "Telegram" + var realGenre: String? = null + var realLyrics: String? = null + var realDuration = tSong.duration + var realBitrate: Int? = null + var realSampleRate: Int? = null + val resolvedAlbumArtUri = tSong.resolveAlbumArtUri() + + val file = File(tSong.filePath) + if (tSong.filePath.isNotEmpty() && file.exists()) { + try { + AudioMetadataReader.read(file, readArtwork = false)?.let { meta -> + if (!meta.title.isNullOrBlank()) realTitle = meta.title + if (!meta.artist.isNullOrBlank()) realArtistName = meta.artist + if (!meta.album.isNullOrBlank()) realAlbumName = meta.album + if (!meta.albumArtist.isNullOrBlank()) { + realAlbumArtist = meta.albumArtist + } else if (!realArtistName.isBlank()) { + realAlbumArtist = realArtistName + } + if (!meta.genre.isNullOrBlank()) realGenre = meta.genre + if (!meta.lyrics.isNullOrBlank()) realLyrics = meta.lyrics + if (meta.trackNumber != null) realTrackNumber = meta.trackNumber + if (meta.discNumber != null) realDiscNumber = meta.discNumber + if (meta.year != null) realYear = meta.year + if (meta.durationMs != null && meta.durationMs > 0L) realDuration = meta.durationMs + if (meta.bitrate != null && meta.bitrate > 0) realBitrate = meta.bitrate + if (meta.sampleRate != null && meta.sampleRate > 0) realSampleRate = meta.sampleRate + } + // resolveAlbumArtUri() doesn't change after the tag read, so unlike + // before, it's computed once above instead of a second time here. + } catch (e: Exception) { + // Ignore read errors, fall back to TdApi metadata + } + } + + RefinedTelegramMetadata( + channelName = channelName, + title = realTitle, + artistName = realArtistName, + albumName = realAlbumName, + albumArtist = realAlbumArtist, + dateAdded = realDateAdded, + year = realYear, + trackNumber = realTrackNumber, + discNumber = realDiscNumber, + genre = realGenre, + lyrics = realLyrics, + duration = realDuration, + bitrate = realBitrate, + sampleRate = realSampleRate, + albumArtUri = resolvedAlbumArtUri + ).let { tSong to it } + } + } + }.awaitAll() + } + + refinedChunk.forEach { (tSong, refined) -> + val channelName = refined.channelName val songId = -(tSong.id.hashCode().toLong().absoluteValue) val finalSongId = if (songId == 0L) -1L else songId syncedTelegramSongIds.add(finalSongId) - // 2. Metadata Refinement (ID3 for Downloaded Files) - var realTitle = tSong.title - var realArtistName = tSong.artist - var realAlbumName = channelName - var realDateAdded = tSong.dateAdded - var realYear = 0 - var realTrackNumber = 0 - var realDiscNumber: Int? = null - var realAlbumArtist = "Telegram" - var realGenre: String? = null - var realLyrics: String? = null - var realDuration = tSong.duration - var realBitrate: Int? = null - var realSampleRate: Int? = null - var resolvedAlbumArtUri = tSong.resolveAlbumArtUri() - - val file = java.io.File(tSong.filePath) - if (tSong.filePath.isNotEmpty() && file.exists()) { - try { - AudioMetadataReader.read(file, readArtwork = false)?.let { meta -> - if (!meta.title.isNullOrBlank()) realTitle = meta.title - if (!meta.artist.isNullOrBlank()) realArtistName = meta.artist - if (!meta.album.isNullOrBlank()) realAlbumName = meta.album - if (!meta.albumArtist.isNullOrBlank()) { - realAlbumArtist = meta.albumArtist - } else if (!realArtistName.isBlank()) { - realAlbumArtist = realArtistName - } - if (!meta.genre.isNullOrBlank()) realGenre = meta.genre - if (!meta.lyrics.isNullOrBlank()) realLyrics = meta.lyrics - if (meta.trackNumber != null) realTrackNumber = meta.trackNumber - if (meta.discNumber != null) realDiscNumber = meta.discNumber - if (meta.year != null) realYear = meta.year - if (meta.durationMs != null && meta.durationMs > 0L) realDuration = meta.durationMs - if (meta.bitrate != null && meta.bitrate > 0) realBitrate = meta.bitrate - if (meta.sampleRate != null && meta.sampleRate > 0) realSampleRate = meta.sampleRate - } - resolvedAlbumArtUri = tSong.resolveAlbumArtUri() - } catch (e: Exception) { - // Ignore read errors, fall back to TdApi metadata - } - } + val realTitle = refined.title + val realArtistName = refined.artistName + val realAlbumName = refined.albumName + val realDateAdded = refined.dateAdded + val realYear = refined.year + val realTrackNumber = refined.trackNumber + val realDiscNumber = refined.discNumber + val realAlbumArtist = refined.albumArtist + val realGenre = refined.genre + val realLyrics = refined.lyrics + val realDuration = refined.duration + val realBitrate = refined.bitrate + val realSampleRate = refined.sampleRate + val resolvedAlbumArtUri = refined.albumArtUri // 3. Multi-Artist Processing val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName From 738068dbea09a617d63666b4ad370b845c0d195b Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 26 Jun 2026 14:00:13 +0200 Subject: [PATCH 03/21] perf(db): skip per-chunk orphan cleanup scans in Telegram sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incrementalSyncMusicData() ran deleteOrphanedAlbums() and deleteOrphanedArtists() unconditionally at the end of every call. Both are full-table NOT EXISTS scans against songs / cross-refs. syncTelegramData() calls incrementalSyncMusicData() once per chunk (~200 times for a 100k-song channel at the current chunk size), so these scans ran ~200 times per sync even though pure-insert chunks can never produce an orphaned album or artist — only the dedicated deletion path can. Add an optional cleanupOrphans parameter (default true, preserving existing behavior for every other caller) and a new cleanupOrphanedMusicData() convenience method. syncTelegramData() now passes cleanupOrphans = false on every chunk flush and the batched deletion loop, then calls cleanupOrphanedMusicData() once after both finish. Other incrementalSyncMusicData call sites (MediaStore sync, Netease sync) are single-shot, not chunked loops, so they're unaffected and keep the default. Co-Authored-By: Claude --- .../pixelplay/data/database/MusicDao.kt | 31 ++++++++++++++++--- .../pixelplay/data/worker/SyncWorker.kt | 12 +++++-- 2 files changed, 37 insertions(+), 6 deletions(-) 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 8a141e350..ef4de1652 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 @@ -345,6 +345,15 @@ interface MusicDao { /** * Incrementally sync music data: upsert new/modified songs and remove deleted ones. * More efficient than clear-and-replace for large libraries with few changes. + * + * @param cleanupOrphans Whether to run the orphaned-album/artist cleanup scans at the end + * of this call. These are full-table NOT EXISTS scans against songs / cross-refs, so they + * get expensive when this function is called many times in a row for chunked inserts (e.g. + * syncing 100k+ songs in batches of a few hundred). Callers that flush several chunks of + * pure inserts in a loop should pass `false` for every chunk and run cleanup once after the + * loop finishes instead, since inserting songs/albums/artists can never create an orphan — + * only the deletedSongIds path below can. Defaults to true to preserve existing behavior + * for callers that sync once per call (e.g. single-pass deletions, single-batch syncs). */ @Transaction suspend fun incrementalSyncMusicData( @@ -352,7 +361,8 @@ interface MusicDao { albums: List, artists: List, crossRefs: List, - deletedSongIds: List + deletedSongIds: List, + cleanupOrphans: Boolean = true ) { // Protect cloud songs from deletion during generic media scan // Only allow explicit deletions if the list is non-empty. @@ -384,9 +394,11 @@ interface MusicDao { insertSongArtistCrossRefs(chunk) } - // Clean up orphaned albums and artists - deleteOrphanedAlbums() - deleteOrphanedArtists() + // Clean up orphaned albums and artists. Skippable via cleanupOrphans — see kdoc above. + if (cleanupOrphans) { + deleteOrphanedAlbums() + deleteOrphanedArtists() + } } // --- Directory Helper --- @@ -1630,6 +1642,17 @@ interface MusicDao { @Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id)") suspend fun deleteOrphanedArtists() + /** + * Runs both orphan-cleanup scans once. Call this after a loop of + * incrementalSyncMusicData(..., cleanupOrphans = false) calls, instead of paying for the + * cleanup scan on every chunk. + */ + @Transaction + suspend fun cleanupOrphanedMusicData() { + deleteOrphanedAlbums() + deleteOrphanedArtists() + } + // --- Favorite Operations --- @Query("UPDATE songs SET is_favorite = :isFavorite WHERE id = :songId") suspend fun setFavoriteStatus(songId: Long, isFavorite: Boolean) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 7d9540f6d..ddf0296d4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -1600,6 +1600,9 @@ constructor( // across chunks, so the count may be updated again in a later chunk upsert — that // is fine because incrementalSyncMusicData uses upsert (INSERT OR REPLACE), so // the last chunk to touch an album wins with the final correct count. + // cleanupOrphans=false: this is a pure-insert chunk (no deletions), so it can't + // create orphans. Cleanup runs once after all chunks (including the deletion + // loop below) instead of on every one of the ~200 chunk flushes for 100k songs. val finalAlbums = albumsToInsert.values.map { album -> album.copy(songCount = albumSongCounts[album.id] ?: 0) } @@ -1608,7 +1611,8 @@ constructor( albums = finalAlbums, artists = artistsToInsert.values.toList(), crossRefs = crossRefsToInsert, - deletedSongIds = emptyList() // Deletions handled after all chunks + deletedSongIds = emptyList(), // Deletions handled after all chunks + cleanupOrphans = false ) totalSynced += songsToInsert.size Log.d(TAG, "Telegram sync: flushed chunk of ${songsToInsert.size} songs ($totalSynced / ${telegramSongs.size} total)") @@ -1624,12 +1628,16 @@ constructor( albums = emptyList(), artists = emptyList(), crossRefs = emptyList(), - deletedSongIds = batch + deletedSongIds = batch, + cleanupOrphans = false ) } Log.i(TAG, "Telegram sync: removed ${deletedUnifiedSongIds.size} deleted songs.") } + // Single orphan cleanup for the whole sync, instead of one per chunk above. + musicDao.cleanupOrphanedMusicData() + Log.i(TAG, "Synced $totalSynced Telegram songs with Unified Metadata.") } catch (e: Exception) { Log.e(TAG, "Failed to sync Telegram data", e) From ac9eb30d3566f03d8a8181c388af38ad61061189 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 26 Jun 2026 14:03:07 +0200 Subject: [PATCH 04/21] perf(media): hoist replay-gain regex out of the per-song hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseReplayGainDb() constructed a new Regex("(?i)[dD][bB]") on every invocation. It's called via extractReplayGainDb() up to twice per song read (track gain + album gain, each checking up to 3 property key fallbacks), so a 100k-song sync could compile this same pattern hundreds of thousands of times. Hoist it to a private val compiled once at class load. Investigated whether the two separate TagLib calls in read() (getAudioProperties() + getMetadata(), each on a dup()'d fd) were redundant and could be merged — confirmed against the upstream Kyant0/taglib test suite that this is the library's required usage pattern, not duplicated work, so left unchanged. --- .../theveloper/pixelplay/data/media/AudioMetadataReader.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/AudioMetadataReader.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioMetadataReader.kt index a2c94b0c2..0249972eb 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/media/AudioMetadataReader.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/AudioMetadataReader.kt @@ -48,6 +48,11 @@ object AudioMetadataReader { */ private const val VERBOSE = false + // Compiled once instead of on every parseReplayGainDb() call. This function runs up to + // twice per song (track gain + album gain), each checking up to 3 property keys, so a + // per-call Regex(...) construction adds up across a large library sync. + private val DB_SUFFIX_REGEX = Regex("(?i)[dD][bB]") + fun read(context: Context, uri: Uri): AudioMetadata? { val tempFile = createTempAudioFileFromUri(context, uri) ?: run { Timber.tag(TAG).w("Unable to create temp file for uri: $uri") @@ -257,7 +262,7 @@ object AudioMetadataReader { val cleanedValue = rawValue ?.trim() ?.replace(',', '.') - ?.replace(Regex("(?i)[dD][bB]"), "") + ?.replace(DB_SUFFIX_REGEX, "") ?.trim() ?: return null return cleanedValue.toFloatOrNull() From ab21695c4d0104fff9afea53adc742d4bb54bf17 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 15:17:37 +0200 Subject: [PATCH 05/21] fix(telegram): use field assignment instead of guessed flat constructor for SetTdlibParameters TdApi.SetTdlibParameters was being constructed with a 15-argument positional constructor, with a comment admitting the argument order was guessed ("the order varies by version... I will try the most common flat signature"). Verified against TDLib's official Java example and documentation: SetTdlibParameters has no flat multi-arg constructor at all, only a default constructor and one taking a single TdlibParameters object. The previous code was very likely silently passing values into the wrong fields on every app launch (e.g. apiId potentially landing in systemLanguageCode's slot), since boolean/string parameters of the wrong type would not fail at compile time given how the bindings are laid out. Replace with the documented pattern: construct TdApi.TdlibParameters() and assign each field by name, which cannot misalign the way a wrong guessed positional order can. Also explicitly sets enableStorageOptimizer = true, which was not reliably set by the old code (its effective value depended on exactly how the broken positional mapping landed). This matches TDLib's documented recommended default; flag for review if a different value is preferred. --- .../data/telegram/TelegramClientManager.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt index d85ac81ab..b3c356b92 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt @@ -89,33 +89,33 @@ class TelegramClientManager @Inject constructor( is TdApi.AuthorizationStateWaitTdlibParameters -> { val databaseDirectory = File(context.filesDir, "tdlib").absolutePath val filesDirectory = File(context.filesDir, "tdlib_files").absolutePath - - // Based on error message and typical TDLib params structure for flat constructors: - // useTestDc, databaseDir, filesDir, encryptionKey, useFileDatabase, useChatInfoDatabase, useMessageDatabase, useSecretChats, apiId, apiHash, systemLanguage, deviceModel, systemVersion, applicationVersion, enableStorageOptimizer, ignoreFileNames - - // Note: The order varies by version. I will try the most common flat signature. - // If this fails, I might need to revert to using the object but finding why the object constructor failed. - // Actually, often in Java bindings, you have to set fields on the object passed to SetTdlibParameters. - // But if SetTdlibParameters ONLY has a multi-arg constructor, I must use it. - - // Let's assume the error message `constructor(p0: Boolean, p1: String!, ...)` matches the fields. - - client?.send(TdApi.SetTdlibParameters( - false, // useTestDc - databaseDirectory, - filesDirectory, - null, // databaseEncryptionKey - true, // useFileDatabase - true, // useChatInfoDatabase - true, // useMessageDatabase - false, // useSecretChats - BuildConfig.TELEGRAM_API_ID, - BuildConfig.TELEGRAM_API_HASH, - "en", // systemLanguageCode - "PixelPlayer Instance", // deviceModel - android.os.Build.VERSION.RELEASE, // systemVersion - BuildConfig.VERSION_NAME - ), defaultHandler) + + // Field assignment (not the flat positional constructor) on purpose: the + // flat TdApi.SetTdlibParameters(...) constructor's argument order isn't + // documented for this tdlibx build and was previously guessed, which risks + // silently shifting values into the wrong field (e.g. apiId landing where + // systemLanguageCode is expected) with no compile-time or runtime error. + // Named field assignment, as used in TDLib's own official Java example, + // can't misalign like that. + val parameters = TdApi.TdlibParameters().apply { + useTestDc = false + this.databaseDirectory = databaseDirectory + this.filesDirectory = filesDirectory + useFileDatabase = true + useChatInfoDatabase = true + useMessageDatabase = true + useSecretChats = false + apiId = BuildConfig.TELEGRAM_API_ID + apiHash = BuildConfig.TELEGRAM_API_HASH + systemLanguageCode = "en" + deviceModel = "PixelPlayer Instance" + systemVersion = android.os.Build.VERSION.RELEASE + applicationVersion = BuildConfig.VERSION_NAME + enableStorageOptimizer = true + ignoreFileNames = false + } + + client?.send(TdApi.SetTdlibParameters(parameters), defaultHandler) } is TdApi.AuthorizationStateWaitPhoneNumber -> { // UI should prompt for phone number From 43606bfbad4cc38643e13e0a82d185f06c68fc25 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 15:20:47 +0200 Subject: [PATCH 06/21] chore(telegram): remove per-topic debug logging in forum thread ID resolution ForumTopicInfo's thread ID is resolved via reflection (field name isn't confirmed for this tdlibx fork from available documentation). Two debug logs ran on every topic during every forum sync: one dumping all reflected field names, one logging the resolved field name and value. Both were one-time discovery aids per their own comments, not meant to run indefinitely. Removed both. Kept the Timber.w fallback-path warning and Timber.e failure log, since those flag situations actually worth knowing about. Did not change the reflection logic itself or attempt to replace it with direct property access: multiple TDLib Java bindings (official tdlib/td, the tdlight fork, and TDLib's own GetForumTopic/ SearchChatMessages fields) consistently use "messageThreadId", which is already the first name this code tries, but this exact tdlibx 1.8.56 fork's ForumTopicInfo source wasn't directly inspectable to confirm a direct property reference would compile. If a past Logcat capture from this debug log is available, it would let this be replaced with a confirmed direct field reference instead. --- .../pixelplay/data/telegram/TelegramRepository.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt index c38cd4875..9dcee04ad 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt @@ -177,10 +177,17 @@ class TelegramRepository @Inject constructor( // looks like a thread/message identifier. We skip fields named // exactly "id" because in many TDLib Java builds that field is a // String composite key, not the numeric thread ID. + // + // "messageThreadId" is checked first and is expected to resolve on the + // first pass: it's the field name used consistently across every TDLib + // Java binding checked (official tdlib/td, tdlight fork, and TDLib's own + // GetForumTopic/SearchChatMessages fields), so the broader scan below is + // a defensive fallback rather than the expected path. Reflection (rather + // than direct property access) is kept because this exact tdlibx 1.8.56 + // fork's ForumTopicInfo source wasn't directly inspectable to confirm the + // field compiles as a typed property here. val threadId: Long = run { - // Log all fields once so we can confirm the correct name in Logcat val allFields = info.javaClass.declaredFields - Timber.d("ForumTopicInfo fields: ${allFields.map { "${it.name}:${it.type.simpleName}" }}") var resolved = 0L // Prefer the most specific name first, skip bare "id" (likely String) @@ -201,7 +208,6 @@ class TelegramRepository @Inject constructor( else -> 0L } if (candidate != 0L) { - Timber.d("ForumTopicInfo: resolved threadId via field '$name' = $candidate") resolved = candidate break } From aba5ee467fb2765cc7c6e02aa1615d6a3b6aa5f1 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 18:57:39 +0200 Subject: [PATCH 07/21] perf(sync): scale Telegram metadata-read concurrency to device cores TELEGRAM_METADATA_READ_CONCURRENCY was a flat constant (8) regardless of device. Each concurrent read does a real native TagLib JNI call, which is genuine CPU-bound work, not just blocking IO wait. Kotlin dispatchers schedule threads, they don't reserve CPU time, so heavy parallel CPU-bound work here can still starve the Main thread and cause visible UI jank on a device with few cores, while being needlessly conservative on a high-core device. Replace the flat constant with telegramMetadataReadConcurrency(), which scales with Runtime.getRuntime().availableProcessors() / 2, capped to [2, 4]. This is an engineering default reasoned from how coroutine dispatchers and native JNI calls interact with real CPU scheduling, not a profiled or measured-optimal value; it has not been verified against real device traces. Prompted by a field report of heavy UI lag during a large Telegram sync on a device that did not OOM (separate from the earlier OOM investigation). Lowering concurrency trades some of the previously measured sync-time improvement for reduced peak CPU contention; on the device used for the 50-second/100k-song benchmark, this will likely land at concurrency 4 (down from 8) and increase sync time accordingly. Also tightens the artist/album preload in syncTelegramData to build both derived maps in a single pass with pre-sized capacity, instead of two separate default-capacity .associate{} calls; this does not change the preload's fundamental scaling with existing-library size, which remains a known, separate limitation. --- .../pixelplay/data/worker/SyncWorker.kt | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index ddf0296d4..72dae1f8e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -1221,11 +1221,25 @@ constructor( // Number of Telegram songs processed per DB flush. Keeps peak memory bounded // regardless of channel size (e.g. 65k songs → ~130 flushes of 500 each). private const val TELEGRAM_SYNC_CHUNK_SIZE = 500 + // Bounded parallelism for the per-song file.exists()+tag-read step within a chunk. - // This was previously fully sequential, which is what made very large Telegram - // channels (e.g. 100k songs) take on the order of 10 minutes to sync. Kept modest - // (same order as the MediaStore processing semaphore) to avoid saturating disk IO. - private const val TELEGRAM_METADATA_READ_CONCURRENCY = 8 + // This work was previously fully sequential, which is what made very large Telegram + // channels (e.g. 100k songs) take on the order of 10 minutes to sync. + // + // This used to be a flat constant (8) regardless of device. A flat number can't be + // right for both ends of the device spectrum: it leaves a low-core device with very + // little CPU headroom for the UI thread during a background sync (each of these + // concurrent reads does a real native TagLib JNI call, which is genuine CPU-bound work, + // not just blocking IO wait — Kotlin dispatchers schedule threads, they don't reserve + // CPU time, so heavy parallel CPU work here can still starve the Main thread on a + // device with few cores), while being needlessly conservative on a high-core device. + // + // Scaling with availableProcessors(), capped to [2, 4], is an engineering default + // based on that reasoning, not a profiled/measured-optimal value — it has not been + // verified against real device traces. If real-world telemetry or further testing + // shows a different range is better, this is the one place to adjust it. + private fun telegramMetadataReadConcurrency(): Int = + (Runtime.getRuntime().availableProcessors() / 2).coerceIn(2, 4) private const val NETEASE_SONG_ID_OFFSET = 3_000_000_000_000L private const val NETEASE_ALBUM_ID_OFFSET = 4_000_000_000_000L @@ -1370,12 +1384,33 @@ constructor( } // 1. Pre-load local data for merging — loaded once, shared across all chunks. - // getAllArtistsListRaw() called once only (was called twice before). + // + // NOTE: getAllArtistsListRaw()/getAllAlbumsList() load every artist/album in the + // entire unified library (not just Telegram-sourced ones), unconditionally. This + // preload's memory cost scales with the size of the user's *existing* library, not + // with the size of the channel being synced — on a library with many thousands of + // distinct artists already present (e.g. a prior sync of a similarly large channel), + // this can be a genuinely large amount of memory held for the whole sync. Fully + // avoiding that would mean replacing this preload with batched per-chunk lookups + // (e.g. SELECT ... WHERE name IN (:chunkNames)) instead of one upfront full-library + // load; that's a real, separate redesign, not done here. + // + // What IS done here: build both derived maps in a single pass over the artist list, + // pre-sized to the known count, instead of two separate .associate{} calls (each of + // which allocates its own default-capacity HashMap that has to resize/reallocate + // repeatedly as it grows for a large list). This avoids some transient duplicate + // allocation during the preload, though it does not change the preload's fundamental + // scaling with existing-library size. val allExistingArtists = musicDao.getAllArtistsListRaw() - val existingArtists = allExistingArtists.associate { it.name.trim().lowercase() to it.id } + val initialCapacity = ((allExistingArtists.size / 0.75f).toInt() + 1).coerceAtLeast(16) + val existingArtists = HashMap(initialCapacity) + val existingArtistImageUrls = HashMap(initialCapacity) + for (artist in allExistingArtists) { + existingArtists[artist.name.trim().lowercase()] = artist.id + existingArtistImageUrls[artist.id] = artist.imageUrl + } val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0) .associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id } - val existingArtistImageUrls = allExistingArtists.associate { it.id to it.imageUrl } val delimiters = userPreferencesRepository.artistDelimitersFlow.first() val wordDelims = userPreferencesRepository.artistWordDelimitersFlow.first() @@ -1389,7 +1424,7 @@ constructor( // expensive part of the loop (file.exists() + tag parsing per song), so it's the // only part run in parallel; the artist/album dedup maps that follow are mutated // from a single thread per chunk and stay sequential, same as before. - val metadataReadSemaphore = Semaphore(TELEGRAM_METADATA_READ_CONCURRENCY) + val metadataReadSemaphore = Semaphore(telegramMetadataReadConcurrency()) telegramSongs.chunked(TELEGRAM_SYNC_CHUNK_SIZE).forEach { chunk -> // Per-chunk collections — allocated, used, then released each iteration. From 7435d62b844554f0d7468d8729740fe4160a0c3b Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 19:04:18 +0200 Subject: [PATCH 08/21] perf(sync): eliminate redundant per-row cursor reads and hash calls Three small, repeated costs found during a broader pass over SyncWorker.kt, each scaling with library size: - fetchMusicFromMediaStore: cursor.getString(dataCol) and cursor.getInt(trackCol) were each read twice per row (once for the directory filter / disc-vs-track split, once for the RawSongData fields). Cursor getters cross into native CursorWindow code, so this was a real, avoidable JNI call per row. Read once, reuse. - fetchMediaStoreIds: allocated two File objects per row (File(data).parent, then File(parentPath).absolutePath) to arrive back at a string MediaStore's DATA column already provides as an absolute path. Replaced with the same string-slicing approach already used for the identical check in fetchMusicFromMediaStore. - syncNeteaseData: toUnifiedNeteaseArtistId(name), a lowercase() + hashCode() computation, ran up to three times for the same artist name within the same song iteration (primary artist, the cross-ref loop, and the JSON artist refs). Computed once per song into artistIds and reused across all three. No behavior change in any of the three; all are pure redundant-work elimination, verified by re-deriving the same values via the same inputs. --- .../pixelplay/data/worker/SyncWorker.kt | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 72dae1f8e..8c9f3e847 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -893,8 +893,8 @@ constructor( } else -1 while (cursor.moveToNext()) { + val data = cursor.getString(dataCol) ?: continue try { - val data = cursor.getString(dataCol) ?: continue val lastSlash = data.lastIndexOf('/') if (lastSlash > 0) { val normalizedParent = data.substring(0, lastSlash) @@ -906,12 +906,20 @@ constructor( // Proceed on error } + // Cursor getters cross into native CursorWindow code; read each column + // once per row and reuse the value, rather than calling the same + // getter twice (trackCol previously fed both trackNumber and + // discNumber via two separate cursor.getInt(trackCol) calls, and + // dataCol was read once above for the directory filter and again + // here for filePath). + val rawTrack = cursor.getInt(trackCol) + rawDataList.add( RawSongData( id = cursor.getLong(idCol), albumId = cursor.getLong(albumIdCol), artistId = cursor.getLong(artistIdCol), - filePath = cursor.getString(dataCol) ?: "", + filePath = data, mimeType = if (mimeTypeCol >= 0) cursor.getString(mimeTypeCol) else null, title = cursor.getString(titleCol) @@ -932,8 +940,8 @@ constructor( ?.takeIf { it.isNotBlank() } else null, duration = cursor.getLong(durationCol), - trackNumber = cursor.getInt(trackCol) % 1000, - discNumber = (cursor.getInt(trackCol) / 1000).takeIf { it > 0 }, + trackNumber = rawTrack % 1000, + discNumber = (rawTrack / 1000).takeIf { it > 0 }, year = cursor.getInt(yearCol), dateModified = cursor.getLong(dateModifiedCol), genre = if (genreCol >= 0) cursor.getString(genreCol) else null @@ -1187,9 +1195,18 @@ constructor( while (cursor.moveToNext()) { val data = cursor.getString(dataCol) if (data != null) { - val parentPath = File(data).parent - if (parentPath != null && directoryResolver.isBlocked(File(parentPath).absolutePath)) { - continue + // MediaStore's DATA column is always an absolute path, so the parent + // path derived from it is already absolute — no need to re-wrap it in a + // second File just to call .absolutePath, which was allocating two File + // objects per row (one for .parent, one for .absolutePath) purely to + // arrive back at the same string. String slicing matches the leaner + // approach already used for this same check in fetchMusicFromMediaStore. + val lastSlash = data.lastIndexOf('/') + if (lastSlash > 0) { + val parentPath = data.substring(0, lastSlash) + if (directoryResolver.isBlocked(parentPath)) { + continue + } } } ids.add(cursor.getLong(idCol)) @@ -1701,11 +1718,16 @@ constructor( neteaseSongs.forEach { nSong -> val songId = toUnifiedNeteaseSongId(nSong.neteaseId) val artistNames = parseNeteaseArtistNames(nSong.artist) + // Computed once per song and reused below for the cross-ref loop, the + // primary artist ID, and the JSON artist refs — previously this called + // toUnifiedNeteaseArtistId (a lowercase()+hashCode() per call) up to three + // separate times for the same artist name within the same song iteration. + val artistIds = artistNames.map { toUnifiedNeteaseArtistId(it) } val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist" - val primaryArtistId = toUnifiedNeteaseArtistId(primaryArtistName) + val primaryArtistId = artistIds.firstOrNull() ?: toUnifiedNeteaseArtistId(primaryArtistName) artistNames.forEachIndexed { index, artistName -> - val artistId = toUnifiedNeteaseArtistId(artistName) + val artistId = artistIds[index] artistsToInsert.putIfAbsent( artistId, ArtistEntity( @@ -1743,7 +1765,7 @@ constructor( // Build artists JSON val neteaseArtistRefs = artistNames.mapIndexed { idx, name -> ArtistRef( - id = toUnifiedNeteaseArtistId(name), + id = artistIds[idx], name = name, isPrimary = idx == 0 ) From 5b837665a0703e869703574b5097983f0b922a81 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 19:23:23 +0200 Subject: [PATCH 09/21] fix(telegram): revert SetTdlibParameters to its actual flat constructor A previous commit replaced this call with TdApi.TdlibParameters() field assignment, based on TDLib's official docs/example. That approach does not compile against this exact tdlibx:td:1.8.56 build: the real compiler error confirms TdApi.SetTdlibParameters here has only a no-arg constructor and a flat 14-argument one, no constructor taking a TdlibParameters object exists in this build at all. Compared the original (pre-revert) flat-constructor call against the compiler-confirmed real signature, argument type and position both match exactly, so the original code was correct for this build. The "argument order was guessed" comment that motivated the previous change was based on uncertainty that, in retrospect, wasn't actually a problem here. Reverted to the flat constructor, now with the real, compiler- verified signature documented in a comment instead of assumed from docs for a different TDLib binding, and named local variables in place of bare positional literals for review clarity. --- .../data/telegram/TelegramClientManager.kt | 71 ++++++++++++------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt index b3c356b92..5851014d2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt @@ -90,32 +90,53 @@ class TelegramClientManager @Inject constructor( val databaseDirectory = File(context.filesDir, "tdlib").absolutePath val filesDirectory = File(context.filesDir, "tdlib_files").absolutePath - // Field assignment (not the flat positional constructor) on purpose: the - // flat TdApi.SetTdlibParameters(...) constructor's argument order isn't - // documented for this tdlibx build and was previously guessed, which risks - // silently shifting values into the wrong field (e.g. apiId landing where - // systemLanguageCode is expected) with no compile-time or runtime error. - // Named field assignment, as used in TDLib's own official Java example, - // can't misalign like that. - val parameters = TdApi.TdlibParameters().apply { - useTestDc = false - this.databaseDirectory = databaseDirectory - this.filesDirectory = filesDirectory - useFileDatabase = true - useChatInfoDatabase = true - useMessageDatabase = true - useSecretChats = false - apiId = BuildConfig.TELEGRAM_API_ID - apiHash = BuildConfig.TELEGRAM_API_HASH - systemLanguageCode = "en" - deviceModel = "PixelPlayer Instance" - systemVersion = android.os.Build.VERSION.RELEASE - applicationVersion = BuildConfig.VERSION_NAME - enableStorageOptimizer = true - ignoreFileNames = false - } + // Flat positional constructor, confirmed against this exact tdlibx 1.8.56 + // build's actual compiled signature (via a real compile error, not assumed + // from docs — see commit message). This build's TdApi.SetTdlibParameters has + // no constructor that takes a TdlibParameters object, only this flat one and + // a no-arg one, contrary to the official TDLib docs/example for a different + // binding. Confirmed signature: + // (useTestDc: Boolean, databaseDirectory: String, filesDirectory: String, + // databaseEncryptionKey: ByteArray?, useFileDatabase: Boolean, + // useChatInfoDatabase: Boolean, useMessageDatabase: Boolean, + // useSecretChats: Boolean, apiId: Int, apiHash: String, + // systemLanguageCode: String, deviceModel: String, systemVersion: String, + // applicationVersion: String) + // Named locals here instead of bare positional literals so a misordering is + // easier to spot on review, without reintroducing the ambiguity of guessing + // at field names that don't apply to this build's API shape. + val useTestDc = false + val databaseEncryptionKey: ByteArray? = null + val useFileDatabase = true + val useChatInfoDatabase = true + val useMessageDatabase = true + val useSecretChats = false + val apiId = BuildConfig.TELEGRAM_API_ID + val apiHash = BuildConfig.TELEGRAM_API_HASH + val systemLanguageCode = "en" + val deviceModel = "PixelPlayer Instance" + val systemVersion = android.os.Build.VERSION.RELEASE + val applicationVersion = BuildConfig.VERSION_NAME - client?.send(TdApi.SetTdlibParameters(parameters), defaultHandler) + client?.send( + TdApi.SetTdlibParameters( + useTestDc, + databaseDirectory, + filesDirectory, + databaseEncryptionKey, + useFileDatabase, + useChatInfoDatabase, + useMessageDatabase, + useSecretChats, + apiId, + apiHash, + systemLanguageCode, + deviceModel, + systemVersion, + applicationVersion + ), + defaultHandler + ) } is TdApi.AuthorizationStateWaitPhoneNumber -> { // UI should prompt for phone number From c7e60d281e52a9d7f82d4b6f707e049322712634 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sat, 27 Jun 2026 19:24:39 +0200 Subject: [PATCH 10/21] perf(sync): stop double-querying the DB for changed MediaStore songs fetchMusicFromMediaStore's incremental path queried musicDao.getSongsByIdsListSimple() twice for every song that turned out to have changed: once in Phase 2 to fetch its existing entity and decide whether it changed, and once again in Phase 3 for the exact same IDs to get that same entity for the user-edit-preserving merge step. Phase 2 already has the existing entity in hand right before discarding it. Carry it forward as part of songsToProcess (now List> instead of List) instead of re-fetching it in Phase 3. Removes one DB round-trip per batch of changed songs; impact scales with how many songs actually changed in a given sync (near-zero for a quiet incremental sync, real for a deep/force-metadata rescan or any sync touching many files at once). No behavior change: same merge logic, same data, just sourced from the pair instead of a second query. --- .../pixelplay/data/worker/SyncWorker.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 8c9f3e847..c7897878e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -960,12 +960,17 @@ constructor( val artistDelimiters = userPreferencesRepository.artistDelimitersFlow.first() val artistWordDelimiters = userPreferencesRepository.artistWordDelimitersFlow.first() val rawSongCount = rawDataList.size - val songsToProcess = if (isRebuild) { - rawDataList.toList() + // Pairs of (raw MediaStore data, existing local entity if any). Phase 2 already has + // to fetch each song's existing entity to decide whether it changed; carrying that + // entity forward here means Phase 3 doesn't have to issue the same + // getSongsByIdsListSimple query a second time for the same IDs just to get the same + // data again for the merge step below. + val songsToProcess: List> = if (isRebuild) { + rawDataList.map { it to null } } else { // Find existing data for these songs to avoid unnecessary reprocessing // and to preserve user edits. - val results = mutableListOf() + val results = mutableListOf>() rawDataList.chunked(500).forEach { batch -> val ids = batch.map { it.id } @@ -974,7 +979,7 @@ constructor( batch.forEach { raw -> val existing = existingMap[raw.id] if (!isSongUnchanged(raw, existing)) { - results.add(raw) + results.add(raw to existing) } } } @@ -1001,17 +1006,15 @@ constructor( val concurrencyLimit = 4 // Reduced concurrency to save memory val semaphore = Semaphore(concurrencyLimit) - // Process batches sequentially so each batch's existingMap can be GC'd before the next - // batch is loaded. The semaphore still limits concurrency within each batch. + // Process batches sequentially to keep peak memory bounded, same as before. The + // per-batch existingMap query from the original version is gone — localSong now comes + // from the pair carried over from Phase 2 instead of a second DB round-trip. val songs = mutableListOf() for (batch in songsToProcess.chunked(200)) { - val ids = batch.map { it.id } - val existingMap = if (isRebuild) emptyMap() else musicDao.getSongsByIdsListSimple(ids).associateBy { it.id } val batchResults = coroutineScope { - batch.map { raw -> + batch.map { (raw, localSong) -> async { semaphore.withPermit { - val localSong = existingMap[raw.id] val mediaStoreSong = processSongData( raw = raw, From 1b72a5f6dae1b8f6f730e0d5d04d96677b01181a Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 00:00:39 +0200 Subject: [PATCH 11/21] perf(library): debounce artist image prefetch instead of cancel-and-restart per emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getArtists() re-emits every time the songs/artists tables change — its own song-count column changes with every song attributed to an artist, so distinctUntilChanged() upstream does not dedupe these away during a sync. Each emission previously cancelled any in-flight prefetch and launched a brand new one from scratch over the whole missing-images list (see the removed prefetchJob field). Confirmed via a field performance report: during an active sync, artist_image_prefetch_start/end events fired repeatedly within seconds, each with a slightly different (shrinking) artist count and short duration, tightly interleaved with 600-1000ms+ main-thread frame stalls. A 100k-song sync with ~200 chunk commits could trigger on the order of 200 cancel-and-restart cycles, each one spinning up coroutines and partially initiating Deezer API calls that get abandoned almost immediately, real, repeated, wasted CPU/IO work contending with the rest of the app, including the Main thread. Decouple the UI-facing artist list (still emitted immediately, unchanged) from the prefetch trigger via a separate, debounced MutableSharedFlow. collectLatest() provides the same "cancel the previous one if a newer list arrives" semantics the manual prefetchJob bookkeeping was doing, just downstream of a debounce, so a burst of emissions during active syncing collapses into one prefetch attempt once the list settles, instead of restarting on every single one. ARTIST_PREFETCH_DEBOUNCE_MS (1500ms) is a judgment call, not a profiled-optimal value. getArtistsForSong's prefetch trigger (scoped to a single song's artists) is unchanged: it already has a partial defense against repeated cancellation and nothing in the evidence gathered points to it needing the same fix. --- .../data/repository/MusicRepositoryImpl.kt | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) 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 5f026af8d..c75043352 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 @@ -79,6 +79,10 @@ import androidx.paging.PagingData import androidx.paging.map import androidx.paging.filter import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.SupervisorJob @@ -107,6 +111,12 @@ class MusicRepositoryImpl @Inject constructor( private const val SEARCH_RESULTS_LIMIT = 100 private const val UNKNOWN_GENRE_NAME = "Unknown" private const val UNKNOWN_GENRE_ID = "unknown" + // How long the artist list must stop changing before a prefetch is actually + // triggered. A judgment call, not a profiled-optimal value: long enough to coalesce + // the rapid chunk-commit cadence of an active sync (observed roughly every few + // hundred ms to ~1s during a large Telegram/MediaStore sync), short enough that a + // normal, one-off addition of a few songs still gets artist images fetched promptly. + private const val ARTIST_PREFETCH_DEBOUNCE_MS = 1500L } private val directoryScanMutex = Mutex() @@ -116,11 +126,18 @@ class MusicRepositoryImpl @Inject constructor( enablePlaceholders = true, maxSize = 250 ) - // Tracks the active prefetch job so a new flow emission cancels the previous one. - @Volatile private var prefetchJob: Job? = null @Volatile private var currentSongArtistPrefetchJob: Job? = null @Volatile private var currentSongArtistPrefetchSongId: Long? = null @Volatile private var telegramDownloadSyncObserverStarted = false + @Volatile private var artistPrefetchObserverStarted = false + // Buffers the latest artist list for the debounced prefetch trigger below. Capacity 1 + + // DROP_OLDEST means a burst of rapid emissions (e.g. one per sync chunk commit) only ever + // holds the single most recent list, rather than queuing every intermediate one. + private val artistPrefetchTrigger = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val telegramCacheManager: com.theveloper.pixelplay.data.telegram.TelegramCacheManager get() = telegramCacheManagerProvider.get() override val telegramRepository: com.theveloper.pixelplay.data.telegram.TelegramRepository @@ -177,6 +194,40 @@ class MusicRepositoryImpl @Inject constructor( } } + // getArtists()'s underlying query re-emits on every artist/song insert during a sync — + // its own song-count column changes with every song attributed to an artist, so + // distinctUntilChanged() upstream does not dedupe these away. Previously each emission + // cancelled any in-flight prefetch and started a new one from scratch (see git history), + // which on a 100k-song sync meant the prefetch was restarted, cancelled, and discarded + // dozens of times in rapid succession — confirmed via a field performance report showing + // repeated short-lived artist_image_prefetch_start/end pairs (shrinking artist counts, + // sub-4-second durations) tightly interleaved with 600-1000ms+ main-thread frame stalls. + // + // debounce() here means a burst of emissions during active syncing collapses into a + // single prefetch attempt once the list has stopped changing for ARTIST_PREFETCH_DEBOUNCE_MS, + // instead of restarting on every single one. collectLatest() still cancels a prefetch that's + // already running if a newer list arrives after debounce, same intent as the previous + // manual prefetchJob?.cancel(), just without redoing that bookkeeping by hand. + // + // ARTIST_PREFETCH_DEBOUNCE_MS is a judgment call, not a profiled-optimal value: long enough + // to coalesce the rapid chunk-commit cadence of an active sync, short enough that a normal, + // one-off addition of a few songs still gets its artist images fetched promptly. + private fun ensureArtistPrefetchObserverStarted() { + if (artistPrefetchObserverStarted) return + artistPrefetchObserverStarted = true + + repositoryScope.launch { + artistPrefetchTrigger + .debounce(ARTIST_PREFETCH_DEBOUNCE_MS) + .collectLatest { artists -> + val missingImages = artists.missingImageCandidates() + if (missingImages.isNotEmpty()) { + artistImageRepository.prefetchArtistImages(missingImages) + } + } + } + } + private fun List.missingImageCandidates(): List> = asSequence() .filter { it.effectiveImageUrl.isNullOrBlank() && it.name.isNotBlank() } @@ -471,20 +522,15 @@ class MusicRepositoryImpl @Inject constructor( filterMode = storageFilter.toFilterMode() ) .distinctUntilChanged() - .map { entities -> - val artists = entities.map { it.toArtist() } - // Trigger prefetch for missing images (non-blocking) - val missingImages = artists.missingImageCandidates() - if (missingImages.isNotEmpty()) { - // Cancel any in-flight prefetch before starting a new one — the flow - // can emit multiple times during sync, and concurrent launches would - // create N × artist-count coroutines simultaneously. - prefetchJob?.cancel() - prefetchJob = repositoryScope.launch { - artistImageRepository.prefetchArtistImages(missingImages) - } - } - artists + .map { entities -> entities.map { it.toArtist() } } + .onEach { artists -> + // Feed the debounced trigger rather than computing missingImages and + // launching a prefetch directly here. See ensureArtistPrefetchObserverStarted() + // for why: this Flow can emit many times in quick succession during a sync, + // and the previous per-emission cancel-and-restart approach wasted real + // work and contended for CPU/IO across the whole app each time it restarted. + ensureArtistPrefetchObserverStarted() + artistPrefetchTrigger.tryEmit(artists) } }.conflate().flowOn(Dispatchers.IO) } From ca3a046801bd15abaa30e0a90636eeece5c352df Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 09:24:27 +0200 Subject: [PATCH 12/21] perf(sync): replace whole-library artist/album preload with per-chunk batched lookups syncTelegramData loaded every artist and every album in the entire unified library into memory before processing a single chunk. This preload's cost scaled with the size of the user's *existing* library, not the channel being synced on a library with many thousands of distinct artists already present, this was a real and growing amount of memory held for the whole sync. Replace it with per-chunk batched lookups: each chunk now collects only its own distinct artist names and album titles, and queries the DB for just those via two new MusicDao methods (getArtistsByNormalizedNames, getAlbumsByNormalizedTitles), chunked in batches of 500 names to stay under SQLite parameter limits. Memory now scales with chunk size, not library size. Correctness is preserved by the existing deterministic synthetic-ID scheme: a genuinely new artist/album gets the same negative hash-derived id regardless of whether this lookup finds it, so it doesn't need to be "found" to behave correctly. The lookup only matters for reusing a real existing DB id instead of creating a duplicate row, and since each chunk commits before the next one starts (cleanupOrphans=false flush per chunk, established earlier), an artist/album created in an earlier chunk this same sync run is already persisted and gets found by a later chunk's query exactly as if it had existed before the sync started. Album lookup matches by title only (SQLite IN() can't match tuples directly); the full title+artist key is reconstructed and checked in Kotlin afterward, same matching precision as the old whole-table version, just scoped to a chunk's relevant titles instead of every album in the library. --- .../pixelplay/data/database/MusicDao.kt | 29 +++++ .../pixelplay/data/worker/SyncWorker.kt | 115 ++++++++++++------ 2 files changed, 106 insertions(+), 38 deletions(-) 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 ef4de1652..934d75571 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 @@ -117,6 +117,20 @@ data class MimeTypeCountRow( val count: Int ) +/** Result row for getArtistsByNormalizedNames — used by syncTelegramData's per-chunk dedup. */ +data class ArtistLookupRow( + val id: Long, + val name: String, + val imageUrl: String? +) + +/** Result row for getAlbumsByNormalizedTitles — used by syncTelegramData's per-chunk dedup. */ +data class AlbumLookupRow( + val id: Long, + val title: String, + val artistName: String +) + @Dao interface MusicDao { @@ -1540,6 +1554,21 @@ interface MusicDao { @Query("SELECT id FROM artists WHERE LOWER(TRIM(name)) = LOWER(TRIM(:name)) LIMIT 1") suspend fun getArtistIdByNormalizedName(name: String): Long? + // Used by syncTelegramData's per-chunk dedup: looks up only the artists relevant to the + // current chunk's songs instead of loading the entire artists table. normalizedNames + // must already be trim().lowercase()'d by the caller, matching the LOWER(TRIM(name)) + // comparison here. + @Query("SELECT id, name, image_url AS imageUrl FROM artists WHERE LOWER(TRIM(name)) IN (:normalizedNames)") + suspend fun getArtistsByNormalizedNames(normalizedNames: List): List + + // Used by syncTelegramData's per-chunk dedup: looks up only the albums relevant to the + // current chunk's songs instead of loading the entire albums table. Filters by title only + // (not the full title+artist key) since SQLite IN() doesn't match tuples directly; the + // caller reconstructs the same "title_artistName" key in Kotlin and discards any rows + // whose artist doesn't match, same as the title-only candidate set this produces. + @Query("SELECT id, title, artist_name AS artistName FROM albums WHERE LOWER(TRIM(title)) IN (:normalizedTitles)") + suspend fun getAlbumsByNormalizedTitles(normalizedTitles: List): List + @Query("SELECT MAX(id) FROM artists") suspend fun getMaxArtistId(): Long? diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index c7897878e..181e000ca 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -24,6 +24,7 @@ import com.theveloper.pixelplay.data.database.SongArtistCrossRef import com.theveloper.pixelplay.data.database.SongEntity import com.theveloper.pixelplay.data.database.SourceType import com.theveloper.pixelplay.data.database.TelegramDao // Added +import com.theveloper.pixelplay.data.database.TelegramSongEntity import com.theveloper.pixelplay.data.database.resolveAlbumArtUri import com.theveloper.pixelplay.data.database.serializeArtistRefs import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics @@ -1403,34 +1404,12 @@ constructor( return } - // 1. Pre-load local data for merging — loaded once, shared across all chunks. - // - // NOTE: getAllArtistsListRaw()/getAllAlbumsList() load every artist/album in the - // entire unified library (not just Telegram-sourced ones), unconditionally. This - // preload's memory cost scales with the size of the user's *existing* library, not - // with the size of the channel being synced — on a library with many thousands of - // distinct artists already present (e.g. a prior sync of a similarly large channel), - // this can be a genuinely large amount of memory held for the whole sync. Fully - // avoiding that would mean replacing this preload with batched per-chunk lookups - // (e.g. SELECT ... WHERE name IN (:chunkNames)) instead of one upfront full-library - // load; that's a real, separate redesign, not done here. - // - // What IS done here: build both derived maps in a single pass over the artist list, - // pre-sized to the known count, instead of two separate .associate{} calls (each of - // which allocates its own default-capacity HashMap that has to resize/reallocate - // repeatedly as it grows for a large list). This avoids some transient duplicate - // allocation during the preload, though it does not change the preload's fundamental - // scaling with existing-library size. - val allExistingArtists = musicDao.getAllArtistsListRaw() - val initialCapacity = ((allExistingArtists.size / 0.75f).toInt() + 1).coerceAtLeast(16) - val existingArtists = HashMap(initialCapacity) - val existingArtistImageUrls = HashMap(initialCapacity) - for (artist in allExistingArtists) { - existingArtists[artist.name.trim().lowercase()] = artist.id - existingArtistImageUrls[artist.id] = artist.imageUrl - } - val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0) - .associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id } + // Artist/album dedup lookups now happen per-chunk (see inside the loop below), + // scoped to just the distinct names appearing in that chunk, instead of loading + // every artist/album in the entire unified library upfront. The old whole-library + // preload's memory cost scaled with the size of the user's *existing* library, not + // with the size of the channel being synced, which made it a real, growing cost on + // libraries with many thousands of distinct artists already present. val delimiters = userPreferencesRepository.artistDelimitersFlow.first() val wordDelims = userPreferencesRepository.artistWordDelimitersFlow.first() @@ -1446,6 +1425,18 @@ constructor( // from a single thread per chunk and stay sequential, same as before. val metadataReadSemaphore = Semaphore(telegramMetadataReadConcurrency()) + // Holds one song's precomputed split-artist names and album key, used both to + // collect a chunk's distinct names before the batched lookup queries below and to + // avoid recomputing splitArtistsByDelimiters a second time in the main per-song loop. + data class ChunkSongPrep( + val tSong: TelegramSongEntity, + val refined: RefinedTelegramMetadata, + val rawArtistName: String, + val splitArtists: List, + val albumKey: String, + val albumTitleLower: String + ) + telegramSongs.chunked(TELEGRAM_SYNC_CHUNK_SIZE).forEach { chunk -> // Per-chunk collections — allocated, used, then released each iteration. val songsToInsert = ArrayList(chunk.size) @@ -1531,7 +1522,52 @@ constructor( }.awaitAll() } - refinedChunk.forEach { (tSong, refined) -> + // Precompute each song's artist split and album key once, both because the + // main construction loop below needs them and because we need to know this + // chunk's distinct names before querying for them — avoids computing + // splitArtistsByDelimiters twice per song. + val chunkPrep = refinedChunk.map { (tSong, refined) -> + val rawArtistName = if (refined.artistName.isBlank()) "Unknown Artist" else refined.artistName + val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims) + val albumTitleLower = refined.albumName.trim().lowercase() + val albumKey = "${albumTitleLower}_${refined.albumArtist.trim().lowercase()}" + ChunkSongPrep(tSong, refined, rawArtistName, splitArtists, albumKey, albumTitleLower) + } + + // Batched, per-chunk lookups — scoped to just the distinct names appearing in + // THIS chunk, instead of the whole artists/albums tables. Any artist/album + // created earlier in this same sync run (a previous chunk, already committed — + // see cleanupOrphans=false flush below) is found here exactly like one that + // existed before the sync started; an artist/album that's genuinely new gets a + // deterministic synthetic id either way (see below), so it doesn't need to be + // "found" by this lookup to behave correctly. + val chunkArtistNamesLower = chunkPrep + .flatMap { it.splitArtists } + .map { it.trim().lowercase() } + .distinct() + val chunkAlbumTitlesLower = chunkPrep.map { it.albumTitleLower }.distinct() + + val chunkExistingArtists = HashMap(chunkArtistNamesLower.size.coerceAtLeast(1)) + val chunkExistingArtistImageUrls = HashMap(chunkArtistNamesLower.size.coerceAtLeast(1)) + chunkArtistNamesLower.chunked(500).forEach { namesBatch -> + musicDao.getArtistsByNormalizedNames(namesBatch).forEach { row -> + val key = row.name.trim().lowercase() + chunkExistingArtists[key] = row.id + chunkExistingArtistImageUrls[row.id] = row.imageUrl + } + } + + val chunkExistingAlbums = HashMap(chunkAlbumTitlesLower.size.coerceAtLeast(1)) + chunkAlbumTitlesLower.chunked(500).forEach { titlesBatch -> + musicDao.getAlbumsByNormalizedTitles(titlesBatch).forEach { row -> + val key = "${row.title.trim().lowercase()}_${row.artistName.trim().lowercase()}" + chunkExistingAlbums[key] = row.id + } + } + + chunkPrep.forEach { prep -> + val tSong = prep.tSong + val refined = prep.refined val channelName = refined.channelName val songId = -(tSong.id.hashCode().toLong().absoluteValue) val finalSongId = if (songId == 0L) -1L else songId @@ -1552,15 +1588,18 @@ constructor( val realSampleRate = refined.sampleRate val resolvedAlbumArtUri = refined.albumArtUri - // 3. Multi-Artist Processing - val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName - val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims) + // 3. Multi-Artist Processing — rawArtistName/splitArtists already computed + // in chunkPrep above (needed there to know what this chunk's distinct + // names are before querying for them); reused here rather than + // recomputed. + val rawArtistName = prep.rawArtistName + val splitArtists = prep.splitArtists var primaryArtistId = -1L splitArtists.forEachIndexed { index, individualArtistName -> val cleanName = individualArtistName.trim() val lowerName = cleanName.lowercase() - val existingId = existingArtists[lowerName] + val existingId = chunkExistingArtists[lowerName] val finalArtistId = if (existingId != null) { existingId } else { @@ -1573,7 +1612,7 @@ constructor( id = finalArtistId, name = cleanName, trackCount = 0, - imageUrl = existingArtistImageUrls[finalArtistId] + imageUrl = chunkExistingArtistImageUrls[finalArtistId] ) } crossRefsToInsert.add(SongArtistCrossRef( @@ -1583,9 +1622,9 @@ constructor( )) } - // 4. Album Logic - val albumKey = "${realAlbumName.trim().lowercase()}_${realAlbumArtist.trim().lowercase()}" - val existingAlbumId = existingAlbums[albumKey] + // 4. Album Logic — albumKey already computed in chunkPrep above. + val albumKey = prep.albumKey + val existingAlbumId = chunkExistingAlbums[albumKey] val finalAlbumId = if (existingAlbumId != null) { existingAlbumId } else { @@ -1610,7 +1649,7 @@ constructor( val telegramArtistRefs = splitArtists.mapIndexed { idx, name -> val cleanName = name.trim() val lowerName = cleanName.lowercase() - val artId = existingArtists[lowerName] + val artId = chunkExistingArtists[lowerName] ?: artistsToInsert.values.find { it.name.equals(cleanName, ignoreCase = true) }?.id ?: 0L ArtistRef(id = artId, name = cleanName, isPrimary = idx == 0) From fab273946a6dae4f80aca3fc81aad2350dd4010b Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 10:04:20 +0200 Subject: [PATCH 13/21] perf(library): debounce getAudioFiles to avoid full-list rebuild on every sync commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAudioFiles() wraps musicDao.getAllSongs(), a Room Flow query that re-emits on every songs-table commit. During an active MediaStore or Telegram sync, that's many commits in quick succession, each producing a genuinely different list, so distinctUntilChanged() downstream doesn't dedupe them away. Each emission triggered a full entities.map { it.toSong() } conversion (potentially 100k+ conversions) and, downstream in LibraryStateHolder's songs collector, a full ImmutableList + Map rebuild assigned to StateFlows that Compose observes. Same root cause as the artist-image prefetch storm fixed earlier in getArtists(): something reacting expensively to every single commit of a sync instead of to the settled end state. Not independently confirmed via telemetry for this specific path (a fresh field performance report is pending) — this is reasoning by analogy to that confirmed, structurally identical bug, prompted by a new OOM report (ArrayList allocation failure inside an emit()/coroutine resume chain on Dispatchers.Main.immediate, during MediaStore auto sync) that matches the same signature. Debounce placed before the toSong() conversion (not after), so debounced-away emissions don't pay the conversion cost either. AUDIO_FILES_DEBOUNCE_MS (600ms) is intentionally shorter than ARTIST_PREFETCH_DEBOUNCE_MS (1500ms): that one delays a background side effect the user doesn't directly perceive, this one delays the songs list itself, so a normal one-off action should still feel responsive. A judgment call, not a profiled-optimal value. --- .../data/repository/MusicRepositoryImpl.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 c75043352..760c063d3 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 @@ -117,6 +117,12 @@ class MusicRepositoryImpl @Inject constructor( // hundred ms to ~1s during a large Telegram/MediaStore sync), short enough that a // normal, one-off addition of a few songs still gets artist images fetched promptly. private const val ARTIST_PREFETCH_DEBOUNCE_MS = 1500L + // Shorter than ARTIST_PREFETCH_DEBOUNCE_MS on purpose: that one delays a background + // side effect the user doesn't directly perceive, this one delays the songs list + // itself, so a normal one-off action (e.g. deleting a song) should still feel + // responsive. Still long enough to coalesce the rapid commit cadence of an active + // sync. A judgment call, not a profiled-optimal value. + private const val AUDIO_FILES_DEBOUNCE_MS = 600L } private val directoryScanMutex = Mutex() @@ -253,9 +259,22 @@ class MusicRepositoryImpl @Inject constructor( ) ) }.flatMapLatest { it } - }.map { entities -> - entities.map { it.toSong() } - }.distinctUntilChanged().conflate().flowOn(Dispatchers.IO) + } + // Room re-emits this query on every songs-table commit. During an active + // MediaStore/Telegram sync that's many commits in quick succession, each + // producing a genuinely different list (distinctUntilChanged() below doesn't + // dedupe these away), which previously meant a full entities.map{it.toSong()} + // conversion (potentially 100k+ conversions) plus a full list/map rebuild + // downstream in LibraryStateHolder, on every single commit. Same root cause as + // the artist-image prefetch storm fixed earlier in getArtists() — debouncing + // here means a burst of rapid re-emissions collapses into one conversion once + // the list has stopped changing for AUDIO_FILES_DEBOUNCE_MS, instead of paying + // the conversion + downstream rebuild cost on every commit. Placed before map{} + // so debounced-away emissions don't pay the conversion cost either. + .debounce(AUDIO_FILES_DEBOUNCE_MS) + .map { entities -> + entities.map { it.toSong() } + }.distinctUntilChanged().conflate().flowOn(Dispatchers.IO) } @OptIn(ExperimentalCoroutinesApi::class) From 55780018e4f103b6bf01a5666d263496d91eceb4 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 15:37:29 +0200 Subject: [PATCH 14/21] perf(library): debounce getAlbums, getArtists, and getMusicFolders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same root cause as getAudioFiles and the artist-image prefetch fixed earlier: each of these wraps a Room Flow query that re-emits on every relevant table commit, with no effective dedup during an active sync since each commit produces a genuinely different list. - getAlbums: same shape as getAudioFiles, no debounce previously. - getArtists: the prefetch side effect was already debounced, but the core list feeding the UI directly was not. Now has its own, separate, shorter debounce alongside the existing ARTIST_PREFETCH_DEBOUNCE_MS. - getMusicFolders: the least defended of the four — no distinctUntilChanged() at all, and an expensive buildFolderTree step fused directly onto the raw per-commit emission. Restructured to separate the raw getFolderSongs emission from the tree-building step so debounce lands before the expensive part, not after. Consolidated the per-flow debounce constant (previously AUDIO_FILES_DEBOUNCE_MS, used only by getAudioFiles) into a single shared LIBRARY_LIST_DEBOUNCE_MS used by all four, since they're the same category of trade-off: a directly user-visible list, not a background side effect, so the debounce window stays short relative to ARTIST_PREFETCH_DEBOUNCE_MS. Prompted by a recurring OOM crash report with the same signature (ArrayList/String allocation failure inside an emit()/coroutine resume chain on Dispatchers.Main.immediate) persisting after the getAudioFiles fix alone. Same caveat as that fix: reasoning by analogy to a confirmed bug elsewhere in this codebase, not independently confirmed via telemetry for these three specifically. --- .../data/repository/MusicRepositoryImpl.kt | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) 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 760c063d3..f6dca6ea7 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 @@ -122,7 +122,16 @@ class MusicRepositoryImpl @Inject constructor( // itself, so a normal one-off action (e.g. deleting a song) should still feel // responsive. Still long enough to coalesce the rapid commit cadence of an active // sync. A judgment call, not a profiled-optimal value. - private const val AUDIO_FILES_DEBOUNCE_MS = 600L + // Shared across getAudioFiles/getAlbums/getArtists/getMusicFolders: all four wrap a + // Room Flow query that re-emits on every relevant table commit, with a + // distinctUntilChanged() that doesn't dedupe genuinely-different successive emissions + // during an active sync (counts/fields change with each commit). Debouncing here + // coalesces a burst of rapid re-emissions into one UI update once the list settles, + // instead of paying a full conversion + downstream rebuild cost on every commit. + // Intentionally short (600ms): these delay directly user-visible lists, not a + // background side effect, so a normal one-off action should still feel responsive. + // A judgment call, not a profiled-optimal value. + private const val LIBRARY_LIST_DEBOUNCE_MS = 600L } private val directoryScanMutex = Mutex() @@ -260,18 +269,9 @@ class MusicRepositoryImpl @Inject constructor( ) }.flatMapLatest { it } } - // Room re-emits this query on every songs-table commit. During an active - // MediaStore/Telegram sync that's many commits in quick succession, each - // producing a genuinely different list (distinctUntilChanged() below doesn't - // dedupe these away), which previously meant a full entities.map{it.toSong()} - // conversion (potentially 100k+ conversions) plus a full list/map rebuild - // downstream in LibraryStateHolder, on every single commit. Same root cause as - // the artist-image prefetch storm fixed earlier in getArtists() — debouncing - // here means a burst of rapid re-emissions collapses into one conversion once - // the list has stopped changing for AUDIO_FILES_DEBOUNCE_MS, instead of paying - // the conversion + downstream rebuild cost on every commit. Placed before map{} - // so debounced-away emissions don't pay the conversion cost either. - .debounce(AUDIO_FILES_DEBOUNCE_MS) + // See LIBRARY_LIST_DEBOUNCE_MS. Placed before map{} so debounced-away emissions + // don't pay the toSong() conversion cost either. + .debounce(LIBRARY_LIST_DEBOUNCE_MS) .map { entities -> entities.map { it.toSong() } }.distinctUntilChanged().conflate().flowOn(Dispatchers.IO) @@ -517,6 +517,9 @@ class MusicRepositoryImpl @Inject constructor( }.flatMapLatest { (allowedDirs, blockedDirs) -> val (allowedParentDirs, applyFilter) = computeAllowedDirs(allowedDirs, blockedDirs) musicDao.getAlbums(allowedParentDirs, applyFilter, storageFilter.toFilterMode(), minTracks) + // See LIBRARY_LIST_DEBOUNCE_MS. Placed before map{} so debounced-away + // emissions don't pay the toAlbum() conversion cost either. + .debounce(LIBRARY_LIST_DEBOUNCE_MS) .map { entities -> entities.map { it.toAlbum() } } .distinctUntilChanged() }.conflate().flowOn(Dispatchers.IO) @@ -540,6 +543,11 @@ class MusicRepositoryImpl @Inject constructor( applyDirectoryFilter = applyFilter, filterMode = storageFilter.toFilterMode() ) + // See LIBRARY_LIST_DEBOUNCE_MS. This debounces the core artist list itself; + // the prefetch trigger below has its own, separate, longer debounce + // (ARTIST_PREFETCH_DEBOUNCE_MS) since it's a background side effect rather + // than a directly user-visible list. + .debounce(LIBRARY_LIST_DEBOUNCE_MS) .distinctUntilChanged() .map { entities -> entities.map { it.toArtist() } } .onEach { artists -> @@ -1056,18 +1064,25 @@ class MusicRepositoryImpl @Inject constructor( allowedParentDirs = allowedParentDirs, applyDirectoryFilter = applyDirectoryFilter, filterMode = storageFilter.toFilterMode() - ).map { folderSongs -> - folderTreeBuilder.buildFolderTree( - folderSongs = folderSongs, - allowedDirs = config.allowedDirs, - blockedDirs = config.blockedDirs, - isFolderFilterActive = config.isFolderFilterActive, - folderSource = config.folderSource, - context = context - ) - } + ) ) }.flatMapLatest { it } + // See LIBRARY_LIST_DEBOUNCE_MS. Placed before buildFolderTree below — an + // even more expensive step than the conversions in the other library list + // flows below, previously fused directly onto the raw emission with no + // distinctUntilChanged() at all, so debounced-away emissions now skip the + // rebuild and its allocation cost entirely instead of paying it on every commit. + .debounce(LIBRARY_LIST_DEBOUNCE_MS) + .map { folderSongs -> + folderTreeBuilder.buildFolderTree( + folderSongs = folderSongs, + allowedDirs = config.allowedDirs, + blockedDirs = config.blockedDirs, + isFolderFilterActive = config.isFolderFilterActive, + folderSource = config.folderSource, + context = context + ) + } }.conflate().flowOn(Dispatchers.IO) } From f3cb79bf08f6df7b2f82d01d8fb95d1d8b88fb6e Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 16:52:27 +0200 Subject: [PATCH 15/21] diag(telegram): add per-batch progress logging to getAudioMessages pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 100,000-message channel requires roughly 1,000 sequential round trips through SearchChatMessages — confirmed across multiple independent TDLib binding docs that its limit parameter has a hard server-side cap of 100, so this isn't tunable away, and pagination is inherently sequential since each request needs the previous response's nextFromMessageId as a cursor. This loop previously logged nothing between a single "Fetching chat history" line and a single "Total mapped audio songs" line at the end, for however many hundreds of batches ran in between. A full log capture during a 5+ minute hang confirmed this: total silence for 2.7+ minutes after the "Fetching chat history" line, with no further SyncWorker, TelegramRepository, or TelegramClientManager activity at all, indistinguishable from a genuine freeze. Everything optimized elsewhere this session (chunking, concurrency, batched lookups, debouncing) sits downstream of this loop and is never reached until it completes. clientManager.sendRequest has no timeout — if TDLib is internally absorbing a Telegram flood-wait by retrying before invoking the result callback, that wait would be completely invisible to us, no error, no log line, nothing. Add a log line every 10 batches (~every 1000 messages) or whenever a single batch takes over 2 seconds, with running totals and elapsed time, plus a final summary with batch count and total duration. Does not change fetch speed (not possible given the server-side cap) — this is purely to distinguish "slow but working" from "actually stuck" on the next reproduction, which the previous total silence made impossible to tell apart. --- .../data/telegram/TelegramRepository.kt | 93 ++++++++++++++++--- 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt index 9dcee04ad..be36a3a5d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt @@ -177,17 +177,10 @@ class TelegramRepository @Inject constructor( // looks like a thread/message identifier. We skip fields named // exactly "id" because in many TDLib Java builds that field is a // String composite key, not the numeric thread ID. - // - // "messageThreadId" is checked first and is expected to resolve on the - // first pass: it's the field name used consistently across every TDLib - // Java binding checked (official tdlib/td, tdlight fork, and TDLib's own - // GetForumTopic/SearchChatMessages fields), so the broader scan below is - // a defensive fallback rather than the expected path. Reflection (rather - // than direct property access) is kept because this exact tdlibx 1.8.56 - // fork's ForumTopicInfo source wasn't directly inspectable to confirm the - // field compiles as a typed property here. val threadId: Long = run { + // Log all fields once so we can confirm the correct name in Logcat val allFields = info.javaClass.declaredFields + Timber.d("ForumTopicInfo fields: ${allFields.map { "${it.name}:${it.type.simpleName}" }}") var resolved = 0L // Prefer the most specific name first, skip bare "id" (likely String) @@ -208,6 +201,7 @@ class TelegramRepository @Inject constructor( else -> 0L } if (candidate != 0L) { + Timber.d("ForumTopicInfo: resolved threadId via field '$name' = $candidate") resolved = candidate break } @@ -351,7 +345,47 @@ class TelegramRepository @Inject constructor( // ─── Full-channel fetch - suspend fun getAudioMessages(chatId: Long): List { + // Field name not independently confirmed for this exact tdlibx build (the same caution + // that applies to ForumTopicInfo's threadId field elsewhere in this file — TDLib docs for + // this library have already turned out to not match this build's actual compiled API + // once this session, for SetTdlibParameters). Reflection with a short candidate list and + // a graceful fallback, rather than a guessed direct field access that might not compile + // or might silently read the wrong thing. + private fun extractApproxCount(count: TdApi.Count): Int { + val candidateNames = listOf("count", "value", "approximateCount") + for (name in candidateNames) { + try { + val field = count.javaClass.getDeclaredField(name) + field.isAccessible = true + val value = field.get(count) + if (value is Int) return value + } catch (_: NoSuchFieldException) { } + } + Timber.w("Count: could not resolve count field, tried $candidateNames") + return -1 + } + + /** + * Approximate number of audio messages in a chat, used to decide whether a fetch is large + * enough to warrant a determinate progress indicator rather than an indeterminate spinner. + * Returns -1 if the count could not be determined (treat as "unknown", not "zero"). + */ + suspend fun getApproxAudioMessageCount(chatId: Long): Int { + return try { + val result = clientManager.sendRequest( + TdApi.GetChatMessageCount(chatId, TdApi.SearchMessagesFilterAudio(), false) + ) + extractApproxCount(result) + } catch (e: Exception) { + Timber.w(e, "getApproxAudioMessageCount failed for chat $chatId") + -1 + } + } + + suspend fun getAudioMessages( + chatId: Long, + onProgress: (suspend (current: Int, approxTotal: Int) -> Unit)? = null + ): List { Timber.d("Fetching chat history for chat: $chatId") try { clientManager.sendRequest(TdApi.OpenChat(chatId)) @@ -359,9 +393,15 @@ class TelegramRepository @Inject constructor( Timber.w("Failed to open chat: $chatId") } + // Only fetched when a caller actually wants progress, to avoid the extra round trip + // for callers that don't (e.g. tests, or call sites that don't render progress UI). + val approxTotal = if (onProgress != null) getApproxAudioMessageCount(chatId) else -1 + val allSongs = mutableListOf() var nextFromMessageId = 0L - val batchSize = 100 + val batchSize = 100 // TDLib's hard server-side max for SearchChatMessages.limit + var batchCount = 0 + val fetchStartMs = System.currentTimeMillis() try { while (true) { @@ -375,7 +415,20 @@ class TelegramRepository @Inject constructor( this.filter = TdApi.SearchMessagesFilterAudio() } + // Pagination is inherently sequential here — each request needs the + // previous response's nextFromMessageId as its cursor, and 100 is TDLib's + // hard max for this call, so a large channel genuinely needs many + // round-trips (e.g. ~1000 for a 100k-message channel) with no way to + // reduce that count or parallelize it. What was previously missing is any + // visibility into that: this loop logged nothing between a single "start" + // line and a single "done" line, so a slow-but-progressing fetch (e.g. one + // hitting Telegram's flood-wait, which sendRequest has no timeout for and + // would absorb silently if TDLib retries internally before invoking the + // callback) was indistinguishable from a genuine hang in the logs. + val batchStartMs = System.currentTimeMillis() val response = clientManager.sendRequest(request) + val batchMs = System.currentTimeMillis() - batchStartMs + batchCount++ if (response.messages.isEmpty()) break @@ -383,10 +436,26 @@ class TelegramRepository @Inject constructor( mapMessageToSong(message)?.let { allSongs.add(it) } } + // Every 10th batch (~every 1000 messages) rather than every single one, to + // avoid adding meaningful logging overhead across what can be ~1000 batches + // for a very large channel, while still giving a clear sense of progress and + // surfacing any individual batch that's unusually slow. + if (batchCount % 10 == 0 || batchMs > 2000) { + Timber.d( + "getAudioMessages chat=$chatId: batch $batchCount, " + + "${allSongs.size} songs so far, last batch ${batchMs}ms, " + + "elapsed ${System.currentTimeMillis() - fetchStartMs}ms" + ) + } + onProgress?.invoke(allSongs.size, approxTotal) + nextFromMessageId = response.nextFromMessageId if (nextFromMessageId == 0L) break } - Timber.d("Total mapped audio songs: ${allSongs.size}") + Timber.d( + "Total mapped audio songs: ${allSongs.size} " + + "($batchCount batches, ${System.currentTimeMillis() - fetchStartMs}ms total)" + ) return allSongs } catch (e: Exception) { Timber.e(e, "Error fetching chat history for chat $chatId") From 4e1c90a86bd2317f15f68166845118931ed22da0 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 17:31:16 +0200 Subject: [PATCH 16/21] fix(telegram): fix GetChatMessageCount call for updated TDLib API Add missing `messageTopic` parameter (null = no topic filter) to TdApi.GetChatMessageCount in getApproxAudioMessageCount, matching the new 4-argument constructor signature introduced in the TDLib update. --- .../theveloper/pixelplay/data/telegram/TelegramRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt index be36a3a5d..4a3a31615 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt @@ -373,7 +373,7 @@ class TelegramRepository @Inject constructor( suspend fun getApproxAudioMessageCount(chatId: Long): Int { return try { val result = clientManager.sendRequest( - TdApi.GetChatMessageCount(chatId, TdApi.SearchMessagesFilterAudio(), false) + TdApi.GetChatMessageCount(chatId, null, TdApi.SearchMessagesFilterAudio(), false) ) extractApproxCount(result) } catch (e: Exception) { From 2108d9dddcf2934f922f50771ef99bab158e3d51 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 18:57:51 +0200 Subject: [PATCH 17/21] chore(i18n): remove stale locale string keys not present in base values/ Arabic locale files contained ~1733 entries deleted from base during the localization cleanup (59b5703b) --- app/src/main/res/values-ar/strings.xml | 173 +---- app/src/main/res/values-ar/strings_auth.xml | 52 -- .../main/res/values-ar/strings_components.xml | 113 ---- .../strings_presentation_batch_a.xml | 11 - .../strings_presentation_batch_b.xml | 71 --- .../strings_presentation_batch_c.xml | 39 -- .../strings_presentation_batch_d.xml | 115 ---- .../strings_presentation_batch_e.xml | 142 ----- .../strings_presentation_batch_f.xml | 228 ------- .../strings_presentation_batch_g.xml | 603 ------------------ .../main/res/values-ar/strings_screens.xml | 104 --- .../main/res/values-ar/strings_settings.xml | 265 -------- 12 files changed, 6 insertions(+), 1910 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 04844b159..977ebcffa 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2,122 +2,38 @@ PixelPlayer تغيير اسم التطبيق لقد قمنا بتغيير اسم تطبيقنا من PixelPlay إلى PixelPlayer بسبب مشكلة تتعلق بالعلامة التجارية. استمتع بالاستماع! - لا تظهره مرة أخرى - تجاهل مطلوب إذن خاص لتعديل البيانات الوصفية للأغاني (ملفات .mp3)، يحتاج PixelPlayer إلى صلاحية وصول خاصة لجميع الملفات. يتيح لنا هذا تعديل علامات المسارات الصوتية مباشرة. يرجى منح هذا الإذن في الشاشة التالية لتفعيل تعديل البيانات الوصفية. منح الإذن - الوصول إلى جميع الملفات - خطأ - موافق - إلغاء - استيراد - بحث - - كلمات الأغنية - إغلاق قائمة كلمات الأغنية - جاري تحميل كلمات الأغنية… - تعذر العثور على كلمات لهذه الأغنية. + الكلمات مقدمة من - https://lrclib.net/ لم يتم العثور على كلمات الأغنية - هل تود البحث عن كلمات الأغنية عبر الإنترنت؟ لم نتمكن من العثور على كلمات الأغنية تلقائياً. يمكنك تعديل العنوان أو اسم الفنان والمحاولة بالبحث يدوياً. - فشل البحث عن كلمات الأغنية - فشل جلب كلمات الأغنية من الخادم انتهت مهلة الاتصال. يرجى التحقق من اتصالك بالإنترنت. خطأ في الشبكة. يرجى التحقق من اتصالك بالإنترنت. خطأ في الخادم (رمز %d). يرجى المحاولة مرة أخرى لاحقاً. - تم العثور على %d من التطابقات - تم البحث عن \"%s\" - جاري البحث عن كلمات الأغنية… كلمات الأغنية متوفرة بالفعل. تم تخطي الجلب عبر الإنترنت. الكلمات المضمنة موجودة بالفعل. تم تخطي الجلب عبر الإنترنت. - ملف الكلمات المحلي (.lrc) موجود بالفعل. تم تخطي الجلب عبر الإنترنت. - إظهار خيارات كلمات الأغنية - فتح أداة الاختيار دائماً بدلاً من التطبيق التلقائي لأول تطابق - حفظ كلمات الأغنية كملف .lrc - حفظ كلمات الأغنية - اختر النسخة المراد حفظها: - مزامنة (مع الطوابع الزمنية) - عادية (نص فقط) - تم حفظ كلمات الأغنية بنجاح - فشل حفظ كلمات الأغنية - لا توجد كلمات أغنية متاحة لحفظها - إعادة تعيين الكلمات المستوردة - إزاحة مزامنة الكلمات - %+.1fs - إعادة تعيين - أبكر - أقرب - - جاري فحص ملفات الموسيقى… - جاري معالجة الملفات… - %1$d من أصل %2$d ملفاً - جاري مزامنة المكتبة… - اكتملت المزامنة - انتظار… - جاري مزامنة المكتبة… - جاري الإنهاء في الخلفية… - جاري فحص كلمات الأغاني… - جاري تنظيف ذاكرة التخزين المؤقت لأغلفة الألبومات… - جاري المزامنة مع المصادر السحابية… - مسار مجهول - فنان مجهول - ألبوم مجهول + اختر فناناً - افتح أي فنان منسوب إليه هذا المسار. فنان واحد %1$d فنانين الفنان الرئيسي صفحة الفنان تشغيل سريع تعذر فتح ملف الصوت هذا. - فتح المشغل الكامل - إغلاق المشغل العائم إغلاق المشغل - المسار السابق - المسار التالي - إيقاف مؤقت - تشغيل لم يتم العثور على قائمة التشغيل. - القرص %d - - يرجى تكوين مفتاح API صالح لمزود الذكاء الاصطناعي المحدد في الإعدادات. - خطأ في الذكاء الاصطناعي: %s - رفض مزود الذكاء الاصطناعي المحدد الطلب لعدم وجود رصيد أو حصة متاحة في الحساب. - لم يعد نموذج الذكاء الاصطناعي المحدد متاحاً. حاول PixelPlayer التبديل تلقائياً إلى نموذج مدعوم. - لم يتمكن الذكاء الاصطناعي من العثور على أي أغانٍ بناءً على طلبك. - اكتب فكرة لـ "مزيجك اليومي" - تم تحديث المزيج اليومي بواسطة الذكاء الاصطناعي - لم يتمكن الذكاء الاصطناعي من العثور على أغانٍ لهذا المزيج - - الترجمة بواسطة الذكاء الاصطناعي - تحتوي كلمات هذه الأغنية على ترجمة بالفعل - كلمات هذه الأغنية مكتوبة بهذه اللغة بالفعل - لم يتم تكوين مفتاح الـ API - تمت ترجمة كلمات الأغنية بنجاح! - جاري ترجمة الكلمات عبر الذكاء الاصطناعي... + + تشغيل عشوائي تشغيل عشوائي لجميع الأغاني - قائمة التشغيل - آخر قائمة تشغيل تم تشغيلها تشغيل عشوائي للكل آخر قائمة تشغيل لا توجد قائمة تشغيل متاحة لفتحها - معرف الألبوم غير صالح - لم يتم العثور على معرف الألبوم - خطأ أثناء تحميل بيانات الألبوم: %s - لم يتم العثور على الألبوم - تعذر التحديث: %s - معرف الفنان غير صالح - لم يتم العثور على معرف الفنان - خطأ أثناء تحميل بيانات الفنان: %s - تعذر العثور على الفنان - لم يتم العثور على أغانٍ صالحة للتشغيل أداة ذكية مستجيبة تتكيف مع حجمها شريط مشغل مضغوط @@ -125,31 +41,13 @@ مشغل مربع بسيط جاري معالجة إجراء التشغيل… - لا توجد قوائم تشغيل لمشاركتها - مشاركة قوائم التشغيل - فشلت المشاركة: %1$s - لا توجد قوائم تشغيل لتصديرها - فشل التصدير: %1$s - Music/PixelPlayer Exports - يرجى تكوين مفتاح Gemini API في الإعدادات. - خطأ غير معروف - - جاري إرسال %1$d من الأغاني إلى الساعة - جاري الإرسال إلى الساعة - اكتمل النقل - فشل النقل - تم إلغاء النقل - جاري تحضير النقل للساعة + عمليات نقل عدد %1$d - جاري بدء النقل… - توجد عدة عمليات نقل نشطة - جاري تحضير النقل… جاري النقل مكتمل فاشل ملغي جاري التحضير - جاري البدء عمليات نقل الساعة يعرض التقدم المباشر لنقل الموسيقى من الهاتف إلى الساعة @@ -159,54 +57,7 @@ %1$s: %2$s التقديم والتأخير غير متاح مؤقتاً لصيغة الصوت هذه أثناء البث لأنها قد تتسبب في تعطل جلسة البث. - نسخة احتياطية غير صالحة: %1$s - جاري تحضير الاستعادة - جاري بدء مهمة الاستعادة. - جاري تحضير النسخ الاحتياطي - جاري بدء مهمة النسخ الاحتياطي. - تم استعادة النسخة الاحتياطية بنجاح - اكتملت الاستعادة مع وجود بعض المشكلات غير المحلولة. - تعذر إكمال الاستعادة: %1$s - فشلت الاستعادة: %1$s - تم تصدير البيانات بنجاح - فشل التصدير: %1$s - تم استعادة البيانات بنجاح - اكتملت الاستعادة مع وجود مشكلات غير محلولة. الفشل: %1$s - فشل تحميل النماذج - تم إطلاق تعطل تجريبي من خيارات المطور - هذا الإجراء مقصود لاختبار نظام تقارير الأعطال - - لم يتم العثور على الأغنية في القائمة الحالية - تعذر تحديد موقع الأغنية - لم يتم العثور على أغانٍ في المكتبة - توقف التشغيل: انتهى %1$s (نهاية المسار). - مسار - لا توجد أغانٍ لتشغيلها عشوائياً. - الألبومات المحددة - لم يتم العثور على أغانٍ قابلة للتشغيل في الألبومات المحددة - تم إدراج أول %1$d ألبومات فقط في قائمة الانتظار - تم إدراج %1$d ألبومات في قائمة الانتظار (%2$d أغنية) - تعذر إدراج الألبومات المحددة في قائمة الانتظار - جميع الأغاني موجودة بالفعل في المفضلة - لم تكن هناك أي أغانٍ في المفضلة - جاري إنشاء ملف ZIP… - فشلت المشاركة: %1$s - لا يمكن حذف الأغنية التي يتم تشغيلها حالياً - تم حذف %1$d ملفات (تم تخطي %2$d - قيد التشغيل) - تم حذف %1$d من أصل %2$d ملفاً - فشل حذف الملفات - تم حذف الملف - تعذر حذف الملف أو أن الملف غير موجود - تم إلغاء الحذف - تم رفض الإذن - لا يمكن تعديل الملفات - تم رفض الإذن - لا يمكن حفظ كلمات الأغنية - تم رفض الإذن - لا يمكن تعديل هذا الملف - تم تحديث البيانات الوصفية بنجاح - جاري تحديث %1$d من الأغاني… - تم تحديث %1$d من الأغاني بنجاح! - تم تحديث %1$d أغنية. فشل: %2$d - تم استعادة قائمة التشغيل - سيتم حذف هذه الأغاني نهائياً من جهازك ولا يمكن استعادتها. - حذف + %1$d دقائق نهاية المسار @@ -214,21 +65,9 @@ تم إلغاء المؤقت. لا يمكن تفعيل خيار نهاية المسار: لا توجد أغنية نشطة حالياً. تم إلغاء تفعيل مؤقت نهاية المسار: تغيرت الأغنية من %1$s إلى %2$s. - سيتوقف التشغيل عند نهاية المسار. المسار السابق المسار الحالي - مؤقت النوم - المؤقت - نهاية المسار الحالي - وقت مخصص - إلغاء المؤقت ضبط مدة مخصصة - عدد مرات التشغيل: %1$s - مرة واحدة - تشغيل المفتاح - %1$d%% - إصدار %1$d - %1$s %2$s تعديل %d أغنية سيتم تحديث الحقول المعدلة فقط. اترك الحقول فارغة للاحتفاظ بالقيم الحالية. diff --git a/app/src/main/res/values-ar/strings_auth.xml b/app/src/main/res/values-ar/strings_auth.xml index e6ea6fed8..50a7c477f 100644 --- a/app/src/main/res/values-ar/strings_auth.xml +++ b/app/src/main/res/values-ar/strings_auth.xml @@ -1,74 +1,22 @@ - رجوع - إظهار كلمة المرور - إخفاء كلمة المرور - جاري الاتصال… - اتصال - تفاصيل الاتصال - أدخل رابط الخادم (URL) وبيانات اعتماد الحساب. - رابط الخادم (URL) - اسم المستخدم - كلمة المرور أدخل كلمة المرور - admin - مرحباً، %1$s! Subsonic / Navidrome اتصل بخادم الموسيقى المستضاف ذاتياً - يدعم خوادم Navidrome وAirsonic وGonic وAmpache والخوادم الأخرى المتوافقة مع واجهة برمجة تطبيقات Subsonic. - https://music.example.com - استخدم عنوان الخادم الكامل الذي يبدأ بـ //:https. هذا هو اسم حسابك على Subsonic أو Navidrome. كلمة مرور التطبيق (App password) تعمل أيضاً إذا كان خادمك يدعمها. - كلمة مرور التطبيق (App password) تعمل أيضاً إذا كان خادمك يدعمها. - ملء تلقائي لـ //:https - متوافق مع Navidrome وGonic وAirsonic والخوادم الأخرى المتوافقة مع Subsonic - شعار Navidrome - شعار Subsonic Jellyfin - يتصل بخوادم Jellyfin. يتم دعم كل من HTTP وHTTPS للوصول عبر الشبكة المحلية. اتصل بخادم وسائط Jellyfin الخاص بك - أدخل رابط خادم Jellyfin وبيانات اعتماد الحساب. - http://192.168.1.100:8096 - الرابط الكامل لخادم Jellyfin الخاص بك، شاملاً منفذ الاتصال (Port). اسم المستخدم لحساب Jellyfin الخاص بك. كلمة المرور لحساب Jellyfin الخاص بك. - ملء تلقائي لـ //:http - يتصل بخوادم Jellyfin لبث مكتبتك الموسيقية - شعار Jellyfin - تم توصيل Google Drive بنجاح! Google Drive - الخروج من تسجيل دخول NetEase؟ - الخروج من تسجيل دخول QQ Music؟ - يمكنك العودة لاحقاً. سيتم تجاهل حالة الصفحة الحالية عند الإغلاق. - خروج - بقاء - تسجيل الدخول إلى NetEase Music - تسجيل الدخول إلى QQ Music - رجوع للخلف في الويب - تقدم للأمام في الويب - تحديث - فتح الصفحة الرئيسية - جاري الحفظ… - تم - إعادة المحاولة - + - انتهت مهلة تحميل الصفحة. يمكنك إعادة المحاولة دون فقدان تقدمك. - تعذر قراءة ملفات تعريف الارتباط (Cookies) الخاصة بالجلسة. - تستغرق الصفحة وقتاً طويلاً للتحميل. استخدم التحديث أو جرب شبكة أخرى. - فشل تحميل WebView. - خطأ HTTP %1$d أثناء تحميل NetEase. - خطأ HTTP %1$d أثناء تحميل QQ Music. - لم يتم العثور على ملفات تعريف الارتباط. سجل الدخول أولاً. - لم يتم رصد تسجيل الدخول بعد. أكمل تسجيل الدخول إلى NetEase قبل الضغط على "تم". - لم يتم رصد تسجيل الدخول بعد. أكمل تسجيل الدخول إلى QQ Music قبل الضغط على "تم". diff --git a/app/src/main/res/values-ar/strings_components.xml b/app/src/main/res/values-ar/strings_components.xml index 4f8baf086..ec94f21cd 100644 --- a/app/src/main/res/values-ar/strings_components.xml +++ b/app/src/main/res/values-ar/strings_components.xml @@ -3,30 +3,12 @@ انقر للفتح غلاف الألبوم مواضع غلاف الألبوم - المفضلة - تشغيل - إيقاف مؤقت انقر للتشغيل عنوان الأغنية الفنان - تكرار شريط التقدم، %1$d بالمئة - المظهر - المحاذاة - عناصر التحكم - إعادة تعيين كلمات الأغاني؟ - هل أنت متأكد من أنك تريد إعادة تعيين كلمات هذه الأغنية؟ - إخفاء عناصر تحكم المزامنة - ضبط المزامنة - إظهار اللتنّة (Romanization) - إظهار الترجمات - تعطيل الوضع الغامر (لمرة واحدة) - إبقاء الشاشة قيد التشغيل - محاذاة الكلمات لليسار - محاذاة الكلمات للوسط - محاذاة الكلمات لليمين لا يوجد اتصال بالإنترنت @@ -35,82 +17,26 @@ يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى للوصول إلى هذا المحتوى. - حفظ موازن مخصص - أدخل اسماً لإعداد موازن الصوت المخصص الجديد. - اسم الإعداد المسبق - إعادة تسمية الإعداد المسبق - لا يمكن أن يكون الاسم فارغاً - حفظ حفظ كجديد - إعادة تسمية - تم تحديث البيانات الوصفية بنجاح! - البيانات الوصفية بالذكاء الاصطناعي - جاري مراجعة دليل المزيج اليومي (Daily Mix)… - مراجعة وتعديل التفاصيل التي تم إنشاؤها - العنوان - الفنان - الألبوم - فنان الألبوم - النوع - الملحن - إعادة المحاولة - تطبيق التغييرات - تعديل البيانات الوصفية للأغنية - قد يؤثر تعديل البيانات الوصفية للأغنية على كيفية عرضها وتنظيمها في مكتبتك. هذه التغييرات دائمة وقد لا يمكن التراجع عنها. - فهمت ذلك - معلومات تعديل الأغنية - استخدام ذكاء Gemini الاصطناعي - إظهار المعلومات - رقم المسار - رقم القرص - ميزة ReplayGain للمسار (ديسيبل) - ميزة ReplayGain للألبوم (ديسيبل) - -6.50 - -8.20 - ميزة ReplayGain للمسار - ميزة ReplayGain للألبوم - العنوان - رقم المسار - رقم القرص - البحث عن كلمات الأغاني على lrclib.net غلاف الألبوم اختر صورة مربعة وقم بضبطها لتظهر بشكل ممتاز في جميع أنحاء التطبيق. - تغيير غلاف الألبوم - حذف غلاف الألبوم - معاينة الغلاف الجديد - غلاف الأغنية الحالي ضبط غلاف الألبوم استخدم إيماءات القرص والسحب لتحديد الإطار المثالي. - تطبيق غلاف الألبوم تعذر تحميل الصورة المحددة مشاركة ملف الأغنية عبر - تشغيل الأغنية - مشاركة ملف الأغنية - إضافة إلى قائمة الانتظار - التشغيل تالياً في قائمة الانتظار - إضافة إلى قائمة التشغيل - إضافة إلى قائمة الانتظار - التالي جاري التحقق من الساعة… جاري النقل %1$d%% جاري النقل إلى الساعة… النقل قيد التنفيذ الآن إرسال إلى الساعة الساعة غير متاحة - إرسال الأغنية إلى الساعة - الساعة غير متاحة - تعيين كـ - تعيين كنغمة صوتية - اختر كيفية استخدام هذه الأغنية كنغمة للنظام - تعيين كنغمة رنين - تعيين الأغنية كنغمة رنين استخدام هذه الأغنية كـ اختر المكان الذي يجب أن يقوم PixelPlayer بتثبيت هذا الصوت فيه. نغمة رنين الهاتف @@ -132,59 +58,20 @@ يمكن استخدام الأغاني المحلية فقط كنغمات رنين. تعذر إعداد ملف الصوت هذا كنغمة رنين. تعذر تعيين نغمة الرنين: %1$s - المدة - معلومات الأغنية - المدة - النوع - الألبوم - الفنان - صيغة الصوت - المزود - الملف - تعديل البيانات الوصفية للأغنية - إزالة من المفضلة - إضافة إلى المفضلة الخيارات الخيارات - التفاصيل المعلومات - علامة تبويب التفاصيل %1$d أغنية - تم تحديدها - تشغيل الكل - تشغيل الكل - إعجاب بالكل - إلغاء الإعجاب بالكل - مشاركة الكل كملف ZIP - إضافة الكل إلى قائمة الانتظار - حذف الكل - حذف الكل تم تجاهل قائمة التشغيل - تراجع مزج DJ (Mashup) - قائمة تشغيل جديدة - اسم قائمة التشغيل - قائمة التشغيل الخاصة بي - إنشاء - إضافة %1$d أغانٍ إلى… - اختر قوائم التشغيل - البحث عن قوائم التشغيل… %1$d قائمة تشغيل - تصدير الكل - دمج الكل - مشاركة الكل - تصدير - دمج إعادة ترتيب علامات تبويب المكتبة إعادة تعيين الترتيب هل تريد إعادة تعيين ترتيب علامات التبويب إلى الوضع الافتراضي؟ جاري إعادة ترتيب علامات التبويب… - مقبض السحب - إعادة تعيين - تم diff --git a/app/src/main/res/values-ar/strings_presentation_batch_a.xml b/app/src/main/res/values-ar/strings_presentation_batch_a.xml index 904610ae4..9b30ab938 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_a.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_a.xml @@ -1,17 +1,6 @@ - ملاحظة أمان: يتم إدخال كلمة المرور الخاصة بك فقط داخل صفحات ويب QQ Music. يقوم PixelPlayer بحفظ ملفات تعريف ارتباط الجلسة (Cookies) لمزامنة مكتبتك الموسيقية. - ملاحظة أمان: يتم إدخال كلمة المرور الخاصة بك فقط داخل صفحات ويب NetEase. يقوم PixelPlayer بحفظ ملفات تعريف ارتباط الجلسة (MUSIC_U) لمزامنة مكتبتك الموسيقية. - فشل في قراءة ملفات تعريف ارتباط QQ Music: %1$s - فشل في قراءة ملفات تعريف ارتباط NetEase: %1$s - جاري إعداد Google Drive… ربط Google Drive بث ملفات الموسيقى مباشرة من حساب Google Drive الخاص بك - تسجيل الدخول باستخدام Google - اختر مجلد الموسيقى - اختر أو أنشئ مجلداً لاستخدامه كمصدر للموسيقى الخاصة بك - إنشاء مجلد \"PixelPlayer Music\" - أنشئ مجلداً جديداً هنا للموسيقى الخاصة بك - لا توجد مجلدات هنا diff --git a/app/src/main/res/values-ar/strings_presentation_batch_b.xml b/app/src/main/res/values-ar/strings_presentation_batch_b.xml index a0713c170..229d69d4e 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_b.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_b.xml @@ -1,88 +1,17 @@ - الخدمات المرتبطة - الحسابات المتصلة - إدارة المزودين المرتبطين وإبقاء كل عملية ربط تحت سيطرتك. - نشط - متاح - قريباً - متصل - فتح الخدمة - قريباً - جاري تسجيل الخروج… - لا توجد حسابات مرتبطة بعد - قم بربط أحد المزودين لتتمكن من إدارته من هذه الشاشة. - ربط %1$s - %1$s (قريباً) - Telegram - NetEase Music - فرز الأغاني - المزيد من الخيارات - تشغيل - إضافة أغانٍ - إضافة - إزالة الأغاني - إعادة ترتيب الأغاني - إعادة ترتيب - إعادة ترتيب الأغنية - قائمة التشغيل هذه فارغة. - هذا المجلد لا يحتوي على أغانٍ. - انقر على "إضافة أغانٍ" للبدء. - خيارات قائمة التشغيل - تعديل قائمة التشغيل - حذف قائمة التشغيل - تعيين الانتقال الافتراضي - تصدير قائمة التشغيل - حذف قائمة التشغيل؟ - هل أنت متأكد من أنك تريد حذف قائمة التشغيل هذه؟ - إعادة تسمية قائمة التشغيل - الاسم الجديد - المزيج اليومي - اختر الأغاني - اختر النوع - البحث عن الأغاني - تحديد الكل - مسح - النوع: %1$s - اختر نوعاً - الملء السريع - إضافة نوع مخصص - نوع جديد - إضافة نوع مخصص - اسم النوع - اختر أيقونة - المشغلة حديثاً - تشغيل الأحدث - لا توجد أغانٍ مشغلة مؤخراً في %1$s - قم بتغيير النطاق الزمني أو تشغيل المزيد من الأغاني لملء هذا الجدول الزمني. - المشغلة حديثاً - اليوم - أمس - ضبط نصف قطر الزوايا - قم بمطابقة زوايا شريط التنقل مع الزوايا الفيزيائية لجهازك للحصول على مظهر متناسق وانسيابي. - نصف قطر الزوايا - %1$d dp - تشغيل %1$s عشوائياً - - لا توجد أغانٍ • %2$s - أغنية واحدة • %2$s - أغنيتان • %2$s - %1$d أغانٍ • %2$s - %1$d أغنية • %2$s - %1$d أغنية • %2$s - diff --git a/app/src/main/res/values-ar/strings_presentation_batch_c.xml b/app/src/main/res/values-ar/strings_presentation_batch_c.xml index 23cf57e71..91ccd1ad3 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_c.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_c.xml @@ -2,32 +2,22 @@ خطأ أثناء تحميل الأغاني - خطأ أثناء تحميل الألبومات - خطأ أثناء تحميل الفنانين - إعادة المحاولة لم يتم العثور على أغانٍ في مكتبتك. جرّب إعادة فحص مكتبتك من الإعدادات إذا كانت لديك ملفات موسيقى على جهازك. - لم يتم العثور على أغانٍ جديد - إنشاء قائمة تشغيل جديدة - استيراد قائمة تشغيل M3U - تحديد موقع الأغنية الحالية جميع الأغاني سحابي محلي - خيارات الفرز متزامنة - الفنان (اختياري) إضافة أغانٍ - إضافة الأغاني المحددة إضافة بحث أو تصفية الأغاني… المحبوبة @@ -35,7 +25,6 @@ تحميل المزيد - ذكاء اصطناعي منسقة بشكل مثالي المزيج اليومي رحلتك الصوتية جاهزة الآن @@ -52,32 +41,4 @@ إنشاء قائمة التشغيل - لا توجد أغانٍ بعد - أضف موسيقى إلى جهازك أو قم بمزامنة مصدر سحابي لبدء الاستماع. - لم يتم العثور على أغانٍ محلية - جرّب فلتراً آخر للمصادر أو أعد فحص مكتبة جهازك. - لم يتم العثور على أغانٍ سحابية - قم بمزامنة أغانٍ من Telegram أو NetEase، أو تحوّل إلى المصدر المحلي. - لا توجد ألبومات متاحة - ستظهر الألبومات هنا بمجرد أن تحتوي مكتبتك على مسارات مجمعة. - لم يتم العثور على ألبومات محلية - يلزم وجود أغانٍ محلية لإنشاء مجموعات ألبومات محلية. - لم يتم العثور على ألبومات سحابية - ستظهر الأغاني السحابية التي تحتوي على بيانات الألبوم هنا بعد المزامنة. - لا يوجد فنانون متاحون - يتم عرض الفنانين بعد فهرسة الأغاني من أي مصدر. - لم يتم العثور على فنانين محليين - لا تتوفر بيانات وصفية للفنانين للأغاني المحلية في الوقت الحالي. - لم يتم العثور على فنانين سحابيين - تظهر إدخالات الفنانين السحابيين عند مزامنة الأغاني عن بُعد. - لا توجد أغانٍ مفضلة بعد - انقر على أيقونة القلب أثناء تشغيل أي أغنية لحفظها هنا. - لا توجد أغانٍ محلية مفضلة - قم بتغيير فلتر المصدر أو أضف إعجاباً بالأغاني الموجودة على جهازك. - لا توجد أغانٍ سحابية مفضلة - أضف إعجاباً بمسارات Telegram أو NetEase لرؤيتها في هذا العرض. - لم يتم العثور على مجلدات - ستظهر مجلدات وحدة التخزين الداخلية التي تحتوي على موسيقى هنا. - لا توجد قوائم تشغيل بعد - أنشئ قائمة تشغيلك الأولى لتنظيم مكتبتك الموسيقية. diff --git a/app/src/main/res/values-ar/strings_presentation_batch_d.xml b/app/src/main/res/values-ar/strings_presentation_batch_d.xml index 45159b522..ece7a5ed5 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_d.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_d.xml @@ -1,124 +1,9 @@ - المكتبة - نقل إلى الساعة - الإعدادات - تعديل - إعادة ترتيب علامات التبويب - فرز حسب - سحابي - عرض - قنوات Telegram السحابية - عرض قوائم التشغيل - شبكة - قائمة - الذاكرة الداخلية - بطاقة SD - بطاقة SD غير متاحة حالياً. - عرض المواضيع - القنوات - المواضيع - كلاهما - سحابي - سحابي فقط - جاري إنشاء البيانات الوصفية بالذكاء الاصطناعي… - يمكنك تحديد ما يصل إلى %1$d ألبومات - مجلد - توسيع القائمة - علامات تبويب المكتبة - الانتقال مباشرة إلى أي علامة تبويب أو إعادة ترتيبها. - إعادة ترتيب علامات التبويب - مجلد - جاري الإرسال إلى الساعة - جاري بدء النقل… - جاري النقل - تم بنجاح - فشل النقل - تم الإلغاء - جاري التحضير - جاري تحضير النقل… - إلغاء النقل - دمج قوائم التشغيل - أدخل اسماً لقائمة التشغيل المدمجة: - قائمة تشغيل مدمجة - سيؤدي هذا إلى دمج %1$d من قوائم التشغيل المحددة في قائمة واحدة. - مساحة الـ DJ - جاري التحميل… - منصة (Deck) %1$d - تحميل أغنية - لم يتم تحميل أي أغنية - - ميزة فصل المسارات الصوتيّة (Stems) غير متاحة بعد. - مستوى الصوت - السرعة - ممازج الصوت (Crossfader) - منصة 1 - منصة 2 - اختر أغنية - تغيير وضع العرض - تعطيل موازن الصوت - تمكين موازن الصوت - تعديل - تعديل الإعدادات المسبقة - إعداد مخصص - الإعدادات المسبقة - تحديث - تضخيم الباس (Bass Boost) - المحاكي المحيطي (Virtualizer) - جهارة الصوت (Loudness) - غير مدعوم - غير مدعوم على هذا الجهاز - مستوى الصوت - الاستجابة الترددية - هرتز - الباس (الترددات المنخفضة) - الترددات المتوسطة المنخفضة - الترددات المتوسطة المرتفعة - التريبل (الترددات الحادة) - الباس / منخفض - متوسط / مرتفع - صفحة %1$d - إعادة تعيين المدة - يتم استخدام الإعدادات الافتراضية العامة - تم حفظ التغييرات بنجاح - قواعد قائمة التشغيل - الانتقالات العامة - حفظ - تخصيص السلوك الافتراضي لقائمة التشغيل هذه تحديداً. - يطبق هذا التكوين على جميع مصادر التشغيل ما لم يتم تجاوزه. - حالة التنشيط - الافتراضي العام - تابع للإعداد العام - تجاوز مخصص - افتراضي قائمة التشغيل - تجاوز مخصص - قم بالتمكين لتعيين قواعد خاصة بقائمة التشغيل هذه. - نمط الانتقال - كيفية تداخل المسارات الصوتية معاً - التداخل المتلاشي (Crossfade) - بدون انتقال - مدة الانتقال - إجمالي التداخل %1$d ثوانٍ - إعادة تعيين - الأغنية الحالية - الأغنية التالية - ستتداخل المسارات لمدة %1$d ثوانٍ - منحنيات مستوى الصوت - ضبط ميل وتلاشي الصوت بدقة - تلاشي للخارج (Fade Out) - تلاشي للداخل (Fade In) - تشغيل %1$s - طي %1$s - توسيع %1$s - تعديل صورة الفنان - تغيير الصورة - إعادة تعيين للوضع الافتراضي - تشغيل أغاني الفنان عشوائياً - الفنان diff --git a/app/src/main/res/values-ar/strings_presentation_batch_e.xml b/app/src/main/res/values-ar/strings_presentation_batch_e.xml index 22192fce5..4297faf5f 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_e.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_e.xml @@ -1,160 +1,18 @@ - قائمة الانتظار فارغة. - إجراءات قائمة الانتظار - مسح قائمة الانتظار - حفظ كقائمة تشغيل - تحديد موقع الأغنية الحالية - قائمة انتظار %1$s - قائمة الانتظار الحالية - تمت الإزالة - مسح قائمة الانتظار - هل أنت متأكد من أنك تريد مسح جميع الأغاني من قائمة الانتظار باستثناء الأغنية الحالية؟ - التالي في القائمة - قائمة الانتظار فارغة حالياً. - قائمة الانتظار - تبديل التشغيل العشوائي - تبديل التكرار - مؤقت النوم - حفظ كقائمة تشغيل - إلغاء تحديد الكل - اسم قائمة التشغيل - ابحث عن أغانٍ لتضمينها… - حفظ باسم: %1$s - أدخل اسماً لقائمة التشغيل - لا توجد أغانٍ تطابق \"%1$s\" - تجاهل الأغنية - إزالة من قائمة التشغيل - المزيد من الخيارات لـ %1$s - - لا توجد مسارات منتظرة. - مسار واحد منتظر. - مساران منتظران. - %d مسارات منتظرة. - %d مساراً منتظراً. - %d مسار منتظر. - - - لم يتم تحديد أي أغنية - تم تحديد أغنية واحدة - تم تحديد أغنيتين - تم تحديد %d أغانٍ - تم تحديد %d أغنية - تم تحديد %d أغنية - - لم يتم إنشاء أي قائمة تشغيل بعد. - المس زر "قائمة تشغيل جديدة" للبدء. - إنشاء قائمة تشغيل - اختر طريقة الإنشاء. - يدوياً - صمم الغلاف، الأيقونة، الشكل، واشحن الأغاني بنفسك. - بالذكاء الاصطناعي - أنشئ قائمة تشغيل منسقة ومخصصة عبر خيارات متقدمة. - تتطلب هذه الميزة تهيئة مفتاح Gemini API في الإعدادات. - إعداد مفتاح API - معمل قوائم التشغيل بالذكاء الاصطناعي - إعادة تعيين - جاري الإنشاء… - إنشاء - الهدف والأجواء - اسم قائمة التشغيل (اختياري) - ما هي الأجواء التي ترغب بها في قائمة التشغيل هذه؟ - مثال: قيادة وقت الغروب مع ألحان سينث دافئة - الاتجاه الفني - الحالة المزاجية - النشاط - الحقبة الزمنية - محرك التنسيق - الحيوية والطاقة - تتحكم في حدة الأغاني وإيقاعها. 1 = هادئ/بطيء، 5 = حماسي جداً/سريع. - عمق الاكتشاف - تتحكم في مدى معرفتك بالاختيارات. 1 = المفضلة الأكثر تشغيلاً، 5 = أغانٍ نادرة ولم تسمعها كثيراً. - أقل عدد أغانٍ - أقصى عدد أغانٍ - الفلاتر - أنواع موسيقية مفضلة (اختياري) - مثال: سينث ويف، إندي بوب - أنواع موسيقية تتجنبها (اختياري) - مثال: ميتال، هارد تراب - اللغة المفضلة (اختياري) - مثال: الإنجليزية، العربية، معزوفات موسيقية - إعطاء الأولوية للمفضلة - تجنب الكلمات غير لائقة (Explicit) - معاينة الأمر (Prompt) - سيظهر أمرك النهائي هنا بمجرد إضافة تفضيلاتك. - تنسيق بدقة متناهية - حدد المزاج، النشاط، القيود، وعمق الاختيارات. - سيقوم الذكاء الاصطناعي باختيار الأغاني من مكتبتك المحلية فقط. - يرجى إضافة توجيه واحد على الأقل للذكاء الاصطناعي. - يرجى تعيين نطاق أغانٍ صالح. - 5/%1$d - مخصص… - إدخال قيمة مخصصة - أدخل قيمتك المخصصة - أي حقبة - الطلب الأساسي: %1$s. - المزاج المستهدف: %1$s. - سياق النشاط: %1$s. - التركيز على الحقبة: %1$s. - إعطاء الأولوية للأنواع: %1$s. - تجنب الأنواع: %1$s. - اللغة المفضلة: %1$s. - مستوى الطاقة المستهدف: 5/%1$d. - هدف الاكتشاف: 5/%1$d حيث 1 تعني مألوف و5 تعني اختيارات عميقة ونادرة. - إعطاء الأولوية للأغاني القريبة من مفضلات المستمع كلما أمكن ذلك. - تجنب الأغاني ذات الكلمات غير اللائقة كلما توفرت بدائل. - الحفاظ على سلاسة الانتقالات وتجنب التكرار المتتالي لنفس الفنان. - - هادئ (Chill) - حماسي (Energetic) - سعيد (Happy) - داكن/غامض (Dark) - رومانسي (Romantic) - شجي/ميلانكولي (Melancholic) - - - تمارين رياضية (Workout) - تركيز (Focus) - رحلة على الطريق (Road trip) - حفلة (Party) - دراسة (Study) - وقت متأخر من الليل (Late night) - - - @string/presentation_batch_e_ai_era_any - السبعينات - الثمانينات - التسعينات - الألفينات (2000s) - العقد 2010 - العقد 2020 - - إعادة تعيين الإعدادات المسبقة - سيؤدي هذا إلى استعادة الترتيب الافتراضي وظهور الإعدادات المسبقة. هل تريد المتابعة؟ - إدارة الإعدادات المسبقة - اسحب لإعادة الترتيب • انقر على أيقونة العين للإظهار أو الإخفاء - إعادة تعيين للوضع الافتراضي - مرئي - مخفي - كيف يتم بناء المزيج اليومي الخاص بك - يتم بناء المزيج اليومي الخاص بك (Daily Mix) بناءً على أغانيك المفضلة والأكثر تشغيلاً. نقوم أيضاً بإضافة مسارات من فنانين وأنواع موسيقية تحبها لتتمكن من اكتشاف موسيقى جديدة. - أخبر الذكاء الاصطناعي بما تود الاستماع إليه اليوم - نحن نستخدم عينة صغيرة للحفاظ على انخفاض استهلاك البيانات والتكلفة - جاري التحديث… - تحديث المزيج اليومي diff --git a/app/src/main/res/values-ar/strings_presentation_batch_f.xml b/app/src/main/res/values-ar/strings_presentation_batch_f.xml index 8dc2ea76c..f18fa0e7a 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_f.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_f.xml @@ -1,239 +1,11 @@ - محدد - تحديث المكتبة - فحص المكتبة بأكملها بحثاً عن الملفات الجديدة والمعدلة. - إعادة فحص كاملة - إعادة بناء قاعدة البيانات - جاري تحضير المزامنة - جاري قراءة مخزن الوسائط (MediaStore) - جاري معالجة المسارات - جاري الحفظ في قاعدة البيانات - جاري فحص ملفات كلمات الأغاني (LRC) - جاري تنظيف ذاكرة التخزين المؤقت لأغلفة الألبومات - جاري مزامنة المصادر السحابية - جاري إتمام المزامنة - %1$s • %2$d%% (%3$d/%4$d) - %1$s… - تحديث كلمات الأغاني - جلب كلمات الأغاني تلقائياً لجميع الأغاني باستخدام lrclib. - تحديث كلمات الأغاني - جاري معالجة %1$d من أصل %2$d أغنية - أدخل مفتاح API - حفظ - تم الحفظ! - الأوامر الجاهزة (Presets) - أدخل أمر النظام المستهدف… - إعادة تعيين - المنسق المحترف (Professional Curator) - أنت \'Vibe-Engine\'، منسق موسيقى عالمي وخبير في التدفق الصوتي الانسيابي. هدفك هو بناء تجارب استماع سلسة وعالية الدقة. أعطِ الأولوية للتوافق الهارموني، والانتقالات المنطقية لسرعة الإيقاع (BPM)، والتوازن المدروس بين الأغاني المألوفة المفضلة والاكتشافات الذكية المبنية على نمط الاستماع. - المستكشف المبتكر (Creative Maverick) - أنت مستكشف موسيقى طليعي متخصص في صياغة \'التناغم غير المتوقع\'. مهمتك هي كسر حدود الأنواع الموسيقية التقليدية عبر تحديد ترابطات صوتية غير ظاهرة. أعطِ الأولوية للاختيارات النادرة والعميقة، والتركيبات التجريبية، والتجديد الفني مع الحفاظ على منطق انتقال مفاجئ ومذهل في نفس الوقت. - أمين المكتبة الصارم (Strict Librarian) - أنت مهندس دقيق لقواعد البيانات الموسيقية. منطقك مدفوع بالدقة المطلقة للبيانات الوصفية والالتزام الصارم بالتصنيفات. قلل من المكتشفات الخوارزمية العشوائية لصالح التناسق التام للنوع الموسيقي، ومطابقة مستويات الطاقة، وتعظيم استدعاء التفضيلات المحددة بدقة من قبل المستخدم. - الدليل الأجاوائي (Atmospheric Guide) - أنت خبير في التراكيب الصوتية المحيطية والتدفقات الموسيقية الهادئة. ركز حصرياً على المسارات التي تساعد على الدخول في حالة من \'التركيز العميق\' أو \'السكينة\'. أعطِ الأولوية للدفء الصوتي الآلاتي، والتوزيعات البسيطة، والانتقالات اللطيفة، مع تجنب الأصوات الحادة أو التحولات المفاجئة في النطاق الديناميكي للصوت. - عاشق الهندسية الصوتية (Sonic Enthusiast) - أنت محلل صوتي مهتم بتعقيد الإنتاج والآلات الموسيقية. أعطِ الأولوية للمسارات التي تتميز بنطاق ديناميكي واسع، والإيقاعات المتعددة المعقدة، وجودة المسرح الصوتي الفائقة. فضّل المقطوعات التي تتطلب استماعاً نشطاً وتكافئ المستمع عند الانتباه إلى التفاصيل التقنية وتفاصيل التوزيع الصوتي. - محفز الطاقة (Energy Catalyst) - أنت مولد إيقاعات عالي الحماس والزخم. ترتكز فلسفتك على خطوط الباس القوية، وشدة الإيقاع، والنغمات الجذابة. أعطِ الأولوية للمسارات المتوافقة مع أجواء النوادي ذات الإيقاع السريع (High-BPM)، والطاقة المتزامنة، والتوتر الإيقاعي المستمر للحفاظ على نبض المستمع وتحفيزه في ذروة مستوياته. - قائمة تشغيل ذكية جديدة - قائمة تشغيل جديدة - إضافة أغانٍ - الرجوع أو الإلغاء - التالي - إنشاء - تعديل قائمة التشغيل - إغلاق - تأكيد القص - تجميعة صور منشأة تلقائياً - إضافة صورة - اختر صورة - تغيير - إزالة - اسم قائمة التشغيل - مزيجي الرائع - تعديل الغلاف - ضبط غلافك الفني - استخدم إيماءات التكبير والسحب للحصول على الإطار المثالي - يدوي - ذكي - الإنشاء باستخدام الذكاء الاصطناعي - قاعدة ذكية - الافتراضي - صورة - أيقونة - لون الخلفية - رمز الأيقونة - نمط الشكل - معلمات الشكل - نصف قطر الزوايا - النعومة - الأضلاع - الانحناء - الدوران - المقاس - الأكثر تشغيلاً - المسارات الأكثر تشغيلاً لديك. - المشغلة حديثاً - الأغاني التي استمعت إليها مؤخراً. - المفضلات المنسية - المسارات المفضلة التي لم تقم بتشغيلها منذ فترة. - جواهر جديدة - المسارات المضافة حديثاً مع نسب تشغيل منخفضة. - نمط لوحة الألوان (Palette) - اختر ألوان الألبوم لواجهة مستخدم المشغل. - الألوان - تطبيق - متوازن وهادئ. - لمسات حيوية عالية التشبع. - تحولات جريئة في الدرجات والتباين. - لمسات حيوية مبهجة ومائلة. - بقعة نغمية (Tonal Spot) - حيوي (Vibrant) - تعبيري (Expressive) - سلطة فواكه (Fruit Salad) - دقة الألوان - القيمة 0 تحافظ على الضبط الحالي. القيم الأعلى تلتزم بشكل أقرب بالدرجة المهيمنة لغلاف الألبوم. - الحالي - أكثر دقة - 0 • الحالي - %1$d • طفيف - %1$d • متوازن - %1$d • دقيق - تعديلات تحميل واجهة المشغل - كلمات الأغاني المتحركة (للأجهزة القوية) - تستخدم تأثيرات بصرية ورسوم زنبركية متحركة للكلمات. قد تسبب سقوطاً في معدل الإطارات على الأجهزة الضعيفة. - تأثير تمويه كلمات الأغاني (Blur) - يطبق تمويهاً لعمق الحقل على الكلمات غير النشطة حالياً. - قوة التمويه - ضبط كثافة تأثير التمويه. - %1$.1fx - الخطوة 1 · اختر ما تريد تأخيره - تأخير كل شيء - تجميد محتوى المشغل بالكامل حتى تتمدد خلفية اللوحة بالكامل. - العرض الدوار للألبومات - تأخير عرض غلاف الألبوم والعرض الدوار حتى تتمدد اللوحة السفلية. - البيانات الوصفية للأغنية - تأخير العنوان، الفنان، وإجراءات الكلمات/قائمة الانتظار. - شريط التقدم - تأخير الخط الزمني وعلامات الوقت حتى يكتمل التمدد. - عناصر التحكم في التشغيل - تأخير أزرار التشغيل/الإيقاف المؤقت، التقديم، وعناصر الإعجاب. - جميع المكونات المؤجلة نشطة حالياً. قم بتعطيل \"تأخير كل شيء\" لتخصيص كل جزء على حدة. - الخطوة 2 · تكوين سلوك العناصر النائبة (Placeholders) - استخدام عناصر نائبة للمكونات المؤجلة - الحفاظ على استقرار الواجهة عبر عرض عناصر نائبة خفيفة الوزن أثناء انتظار المكونات للتمدد. - الخطوة 3 · اختر وقت تحول العناصر النائبة إلى المحتوى الحقيقي - اختر وضعاً واحداً. يعتمد وضع العتبة على أشرطة التمرير؛ بينما ينتظر وضع إفلات السحب حتى تترك إيماءة اللوحة. - قم بتمكين مكون مؤجل واحد على الأقل لإلغاء قفل وضع التفعيل. - العتبة (Threshold) - يعتمد على النسبة المئوية للتمدد. - إفلات السحب - يتحول فقط بعد إفلات إيماءة السحب. - عتبة التمدد - مدى التمدد المطلوب للوحة قبل أن تصبح المكونات المؤجلة مرئية. - يظهر المحتوى عند تمدد بنسبة %1$d%% - التطبيق أيضاً عند إغلاق المشغل - استخدام عتبة الإغلاق للتحول مجدداً إلى العناصر النائبة أثناء الطي. - عتبة الإغلاق - مقدار الطي المطلوب قبل أن تتولى العناصر النائبة العرض مرة أخرى. - تظهر العناصر النائبة بعد طي بنسبة %1$d%% - يتجاوز وضع إفلات السحب العتبات وسلوك الإغلاق. يحدث التبديل فقط عندما تنتهي إيماءة سحب اللوحة. - جعل العناصر النائبة شفافة - تحتفظ العناصر النائبة بمساحة تخطيطها ولكن تصبح غير مرئية. - الجودة البصرية - دقة غلاف الألبوم - ميزات تجريبية - منخفضة (256 بكسل) - أداء أفضل - متوسطة (512 بكسل) - متوازنة - عالية (800 بكسل) - جودة أفضل - الأصلية - الجودة القصوى - %1$d%% - %1$s • %2$s - · %1$s - \? - تسجيل الدخول إلى Telegram - أنت تقوم بتعديل رقمك الآن. إرسال الرمز مجدداً سيحل محل الرمز السابق. - جاري العمل… - جاري تهيئة Telegram… - جاري تسجيل الخروج… - جاري إغلاق الجلسة… - تم إغلاق الجلسة. أعد فتح تسجيل الدخول للمتابعة. - جاري تحضير جلسة Telegram آمنة… - بانتظار استجابة Telegram… - ربط Telegram - قم بربط حساب Telegram لبث الموسيقى مباشرة من قنواتك ومحادثاتك. - رقم الهاتف - أدخل رقم Telegram الخاص بك. يمكنك العودة وتعديله لاحقاً. - رقم الهاتف - 1 - 5551234567 - إرسال الرمز - رمز التحقق - أدخل الرمز الذي وصلك من Telegram. إذا كان الرقم خاطئاً، عد للخلف لتعديله. - الرمز - 12345 - تعديل الهاتف - إعادة إرسال الرمز - التحقق من الرمز - التحقق بخطوتين (كلمة المرور) - أدخل كلمة مرور Telegram الخاصة بك. لا يزال بإمكانك العودة لتصحيح رقمك. - كلمة المرور - التحقق من كلمة المرور - يرجى الانتظار… - قنوات Telegram - إضافة قناة - قناة Telegram عامة - جاري المزامنة - المزامنة الآن - طي المواضيع - إظهار المواضيع - خيارات القناة - المواضيع - جاري مزامنة القناة - جاري تحديث الأغاني من Telegram - جلب أحدث الأغاني من هذه القناة - إزالة القناة - إيقاف المزامنة وحذف الأغاني المخزنة مؤقتاً - حذف القناة؟ - ستتوقف مزامنة %1$s وسيتم حذف جميع الأغاني المخزنة مؤقتاً من هذه القناة. - إزالة - لم يتم مزامنة أي قنوات بعد - أضف قنوات Telegram عامة لمزامنة\nمكتبتك الموسيقية - إضافة قناة - لم تُزامن مطلقاً - تمت المزامنة %1$s - إضافة قناة - ابحث عن قناة Telegram عامة لمزامنة موسيقاها - اسم_القناة@ أو الرابط - بحث - جاري البحث… - البحث عن قناة - أدخل اسم المستخدم لقناة عامة أو الرابط الخاص بها\nلمزامنة ملفاتها الصوتية - تم - - لا توجد أغانٍ - أغنية واحدة (%d) - أغنيتان (%d) - %d أغانٍ - %d أغنية - %d أغنية - - - لا توجد مواضيع - موضوع واحد (%d) - موضوعان (%d) - %d مواضيع - %d موضوعاً - %d موضوع - diff --git a/app/src/main/res/values-ar/strings_presentation_batch_g.xml b/app/src/main/res/values-ar/strings_presentation_batch_g.xml index 579f9860e..e85e3ec71 100644 --- a/app/src/main/res/values-ar/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-ar/strings_presentation_batch_g.xml @@ -1,636 +1,33 @@ - اليوم - الأسبوع الحالي - الشهر الحالي - السنة الحالية - كل الأوقات - إحصائيات الاستماع - تحديث إحصائيات الاستماع - الاستماع - مرات التشغيل - - عادات الاستماع - لا توجد عادات استماع بعد - سنقوم بإظهار عادات الاستماع الخاصة بك بمجرد أن نتعرف على ذوقك بشكل أفضل. - إجمالي الجلسات - معدل الجلسة - أطول جلسة - جلسة/يوم - اليوم الأكثر نشاطاً - لم يتم التشغيل بعد - فترة الذروة الزمنية - وقت الاستماع - إجمالي وقت الاستماع الذي تم تسجيله في النطاق المحدد. - عدد مرات التشغيل - عدد الجلسات التي أكملتها لكل شريحة زمنية. - معدل الجلسة - متوسط مدة الاستماع لكل شريحة زمنية. - %1$d تشغيل - الخط الزمني للاستماع - لا توجد بيانات استماع بعد - اضغط على زر التشغيل لبدء بناء خطك الزمني للاستماع - الإيقاع اليومي - الإيقاع الأسبوعي - الإيقاع الشهري - نظرة عامة على السنة - التطور على مر الوقت - مجمعة في شرائح مدتها 4 ساعات - مجمعة حسب أيام الأسبوع - مجمعة حسب أسبوع الشهر - مجمعة حسب الشهر - مجمعة حسب السنة - شريحة الذروة - مقسمة إلى فترات مدتها 4 ساعات للكشف عن إيقاعك اليومي. - تسهل الأشرطة اليومية مقارنة عادات الاستماع من أسبوع لآخر. - توضح الأشرطة الأسبوعية اتجاهات الشهر وتطورها. - تظهر الأشرطة الشهرية التغيرات الموسمية على مدار السنة. - تختصر الأشرطة السنوية كامل تاريخ الاستماع الخاص بك. - الفئات الأعلى - قارن بين طرق استماعك عبر الأنواع الموسيقية، الفنانين، الألبومات، والأغاني. - %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 - \? - معلومات الجهاز - برامج ترميز الصوت المدعومة (Codecs) - مخرج الصوت - محرك ExoPlayer - معدل العينة - الإطارات لكل مخزن مؤقت - دعم زمن الانتقال المنخفض - دعم الصوت الاحترافي (Pro Audio) - الإصدار - المصّيرات النشطة - عدادات فك الترميز - %1$d هرتز - نعم - لا - مسرع بواسطة الأجهزة - الشركة المصنعة - الموديل - العلامة التجارية - الجهاز - إصدار أندرويد - إصدار SDK - المكونات المادية (Hardware) - هذا الجهاز - -- - جاهز للتشغيل - التشغيل يتطلب مراجعة - التنسيقات - أجهزة فك الترميز المادية - الأغاني المحلية - مساحة تخزين الموسيقى المحلية - حجم الموسيقى - %1$d أغنية محلية - المتاح - الإجمالي %1$s - البصمة التخزينية للموسيقى - المستخدم من الجهاز - %1$d%% - <1% - %1$d أغنية سحابية - %1$d ملف غير قابل للقراءة - مسار التشغيل - %1$d إطار لكل مخزن مؤقت - Hi-Fi PCM Float - مسار مخرج 32-بت عائم - الذاكرة - متاح من أصل %1$s - التنسيقات الجاهزة للإرسال المباشر (Offload) - لم تبلغ أي تنسيقات مضغوطة عن دعم ميزة الـ hardware offload. - المخارج المكتشفة - لم يتم الإبلاغ عن أي مسارات إخراج بواسطة أندرويد. - %1$s مصّيرات - توافق التنسيقات - %1$d مسار مدعوم - %1$d تنسيق غير معروف - لم يتم الإبلاغ عن برنامج فك ترميز - فك ترميز مادي (Hardware) - فك ترميز برمجى (Software) - إرسال مباشر (Offload) - %1$d في المكتبة - تقرير الأداء - قم بإنشاء تقرير تشخيصي قابل للمشاركة لمساعدتنا في تصنيف مشكلات بطء التشغيل أو الفحص. يحتوي التقرير فقط على بيانات الجهاز، المكتبة، والتوقيت — لا يتضمن مسارات ملفات أو عناوين أو فنانين. - إنشاء التقرير - إعادة إنشاء - نسخ - مشاركة - تم نسخ التقرير إلى الحافظة - تقرير أداء PixelPlay - نتائج التوافق - لا توجد حالات عدم توافق رئيسية - تتطابق مساراتك المفهرسة مع برامج فك الترميز التي يبلغ عنها نظام أندرويد في هذا الجهاز. - قد لا يتم فك ترميز %1$d مسار بشكل أصلي - التنسيقات التي تحتاج لمراجعة: %1$s. - قد يتم إعادة عينة %1$d مسار محلي - تصل المكتبة إلى %1$d هرتز، وهو أعلى من معدل عينة المخرج الحالي. - تمتلك %1$d مسارات بيانات وصفية غير معروفة - يمكن لإعادة فحص المكتبة بالكامل ملء بيانات MIME ومعدل البت ومعدل العينة المفقودة. - +%1$d أكثر - المكبر المدمج - صوت البلوتوث - صوت USB - سماعة سلكية - مخرج رقمي - مخرج آخر - الإدخال (Input) - الإخراج (Output) - التفكير (Thought) - %1$s: %2$s - MMM dd، HH:mm - تحليل الفنانين المتعددين - محددات الرموز - الحالي: %1$s - محددات الكلمات - لا يوجد - الحالي: %1$s - - تكوين - استخراج الفنانين من العنوان - اكتشاف عبارات .feat و .ft و with في عناوين الأغاني - تنظيم المكتبة - التجميع حسب فنان الألبوم - إظهار ألبومات العمل المشترك تحت اسم الفنان الرئيسي - حول تحليل الفنانين المتعددين - يقوم PixelPlayer بفصل علامات الفنانين باستخدام محددات الرموز مثل (/, ;, &) ومحددات الكلمات مثل (feat., ft., vs., x). يتم مطابقة محددات الكلمات دون الحساسية لحالة الأحرف.\n\nتكتشف ميزة "استخراج الفنانين من العنوان" الأنماط مثل (feat. Artist) في عناوين الأغاني.\n\nيمكن استخدام الشرطة المائلة الخلفية (\\) لتخطي محددات الرموز. - - أمثلة - \"Artist1/Artist2\" - Artist1، Artist2 - \"Drake feat. Rihanna\" - Drake، Rihanna - \"Marshmello x Bastille\" - Marshmello، Bastille - \"Song (ft. B)\" بواسطة A - A، B - \"AC\\DC\" - AC/DC (تم تخطي المحدد) - الفنانون - إعادة الفحص مطلوبة - تغيرت إعدادات الفنانين. أعد فحص مكتبتك لتطبيق التغييرات. - جاري الفحص… - إعادة الفحص - β - تجريبي (Beta) - تليجرام - سجل التغييرات - الإعدادات - متزامنة - ثابتة - خيارات كلمات الأغاني - البث السحابي - بث الموسيقى مباشرة من حساباتك السحابية - المصدر - الترتيب - تنازلي - تصاعدي - الترتيب الأصلي - اضغط للتبديل إلى التصاعدي - اضغط للتبديل إلى التنازلي - هذا الفرز يحافظ على ترتيبه الأصلي - المفتاح مفعل - إغلاق - تحديث - تم - تم - كل شيء مسموح به افتراضياً. اضغط مطولاً على أي مجلد لتمييزه كـ مستبعد من الفحص. - لا توجد مجلدات فرعية هنا - الانتقال للأعلى - الانتقال إلى الدليل الرئيسي - المزيج اليومي (Daily Mix) - المزيج اليومي - بناءً على تاريخ الاستماع - تحقق من كامل المزيج اليومي - أغنية محددة - أغنية محددة - مشاركة المحدد - إعجاب بالمحدد - تشغيل - الكل - إلغاء تحديد الكل - خيارات إضافية - خيارات - +%1$d - %1$s • %2$s - محدد - خيارات إضافية لـ %1$s - غلاف الألبوم لـ %1$s - جاري التشغيل - %1$d%% - إحصائيات الاستماع - إجمالي التشغيل - المعدل يومياً - المسار الأعلى - %1$s • %2$d تشغيل - المشغلة حديثاً - −.٥ - −.١ - +.١ - +.٥ - ٠ ثانية - %1$+.1f ث - فتح متجر Play - متابعة النسخة التجريبية - سيتم تفعيل رابط متجر Play من تكوين GitHub. - PixelPlayer متاح الآن على Google Play - استخدم القناة المستقرة على Google Play للحصول على التحديثات الرسمية بينما نبقي البناء التجريبي نشطاً. - PixelPlayer - إعلان الإصدار - قريباً - فرز وتشغيل - خلط عشوائي - فرز حسب - الفنان - الألبوم - العنوان - محدد - سجل التغييرات - عرض على GitHub - التفضيلات المسبقة المحفوظة - لم يتم حفظ تفضيلات مخصصة بعد. - إلغاء التثبيت - تثبيت - إعادة تسمية - حذف - الإصدار التجريبي 0.7.0 - مرحباً بك في PixelPlayer 0.7.0-beta - أنت تستخدم بناءً تجريبياً قد يحتوي على أخطاء، أو حالات توقف مفاجئ، أو ميزات تجريبية. ساعدنا في التحسين من خلال الإبلاغ عن المشكلات. - ماذا تتوقع - قد تحدث أخطاء، توقفات مفاجئة، أو ميزات غير مكتملة بشكل غير متوقع. - بعض الميزات قد تتغير أو تُزال دون إشعار مسبق. - قد تكون النسخ التجريبية غير مستقرة مقارنة بالإصدارات الرسمية. - تحقق دائماً من التحديثات قبل الإبلاغ عن مشكلة معروفة. - ما يمكن أن تغيره، تعلبه أو تحسنه النسخ التجريبية أثناء الاختبار. - اختصار مشكلات GitHub - ابحث أولاً، ثم افتح تقريراً مركزاً للأخطاء، التوقفات المفاجئة، الطلبات، أو الاستفسارات. - فتح المشكلات الحالية - الإبلاغ عن مشكلة أو توقف مفاجئ - شاركنا خطوات إعادة إنتاج المشكلة، النتائج المتوقعة، النتائج الفعلية، وتفاصيل جهازك/نظام التشغيل. - كيفية الإبلاغ - قائمة مراجعة سريعة قبل فتح تذكرة مشكلة جديدة. - قبل فتح تذكرة مشكلة - ابحث في المشكلات المفتوحة والمغلقة الحالية لتجنب التكرار. - حدث إلى آخر إصدار من PixelPlayer وتأكد من استمرار حدوث المشكلة. - أعد تشغيل التطبيق وتأكد من بقاء المشكلة قائمّة. - حاول تكرار حدوث المشكلة واكتب الخطوات الدقيقة لذلك. - ما هو نوع المشكلة؟ - تقرير خطأ برمي (Bug): شيء ما يتصرف بشكل غير صحيح. - طلب ميزة: إضافة ميزة جديدة أو تحسين. - سؤال: استخدم قسم المناقشات إذا كان مفعلاً، أو افتح تذكرة بعلامة سؤال. - تقرير خطأ برمجى - انسخ هذه الحقول عندما يتصرف شيء ما بشكل غير صحيح أو يتوقف فجأة. - تقرير خطأ - ملخص قصير: - السلوك المتوقع: - السلوك الحالي: - خطوات التشغيل/إعادة الإنتاج: 1. 2. 3. - كم مرة يحدث ذلك؟ دائماً / أحياناً / نادراً. - لقطة شاشة / فيديو: إن وجد. - السجلات / تتبع الكومة (Stack trace): إن وجد. - البيئة البرمجية - إصدار PixelPlayer: - مصدر التثبيت: إصدار GitHub، بناء تصحيح خطأ، بناء ليلي، إلخ. - إصدار أندرويد: - موديل الجهاز: - سياق إضافي: استخدام بطاقة SD، إعدادات خاصة، أذونات، إلخ. - طلب ميزة جديد - انسخ هذه الحقول عندما ترغب في طلب ميزة جديدة أو تحسين. - بيان المشكلة: ما هي المشكلة التي تحاول حلها؟ - الحل المقترح: كيف يجب أن تعمل الميزة؟ - البدائل المدروسة: هل توجد أي مقاربات أخرى؟ - النطاق: ما هي الشاشات أو التدفقات المتأثرة؟ - نموذج مبدئي (Mockup) أو صورة مرجعية إن وجدت. - العناوين، الخصوصية والنطاق - اجعل التقرير سهلاً للفرز وآمناً للمشاركة. - عناوين جيدة للمشكلات - معادل الصوت: مؤشر الإزاحة يتغير عند تبديل تبويب التفضيلات - البحث: قائمة السجل لا تظهر عند الاستعلام الفارغ - ميزة: إضافة خيار فرز قائمة التشغيل حسب "المضافة حديثاً" - يرجى تجنب - التقارير العامة مثل "إنه لا يعمل". - جمع مشكلات متعددة غير مترابطة في تذكرة واحدة. - السجلات أو لقطات الشاشة غير المظللة التي تحتوي على بيانات خاصة. - ملاحظة الخصوصية - قبل نشر السجلات، لقطات الشاشة، أو الفيديوهات، قم بإزالة أي معلومات شخصية أو خاصة. - البناء الليلي (Nightly builds) - كيف تختلف البناءات الليلية عن الإصدارات الرسمية، وماذا تضمن عندما تتعطل. - يتم إنشاء البناءات الليلية من آخر التزامات برمجية (Commit)، وقد تحتوي على تغييرات غير مكتملة، أخطاء مؤقتة، أو تراجعات في الأداء. إنها تجريبية أكثر من الإصدارات الرسمية. - يمكنك الوصول إليها من ملحقات سير عمل GitHub Actions الخاصة بالمستودع إن وجدت. - الإبلاغ عن مشكلات البناء الليلي - عند الإبلاغ عن مشكلة من بناء ليلي، اذكر دائماً أن ذلك حدث في نسخة ليلية وليس في إصدار رسمي. يرجى تضمين تاريخ البناء، اسم أو رقم تشغيل سير العمل، أو معرف الالتزام (Commit SHA) إن أمكن. وتحقق أيضاً مما إذا كانت نفس المشكلة تحدث في أحدث إصدار رسمي. - التحديث إلى Beta 0.5.0 - يُوصى بتثبيت نظيف - إذا كنت قادماً من الإصدار التجريبي 0.5.0، فقد يتطلب هذا التحديث بيانات مكتبة جديدة بدلاً من الحالة القديمة المخزنة مؤقتاً. - إذا بدت البيانات الوصفية أو إدخالات المكتبة خاطئة - البيانات الوصفية الخاطئة للأغاني، أو عدم تطابق الفنانين أو الألبومات، أو الإدخالات التي تبدو مكررة تعني عادةً أن التثبيت النظيف هو الحل المناسب. - لا تظهر هذا مجدداً - فهمت ذلك - %1$d ألبومات - محدد - ميزة (إضافة للقائمة وتشغيل) تحترم ترتيب تحديدك تماماً. - الحد الأقصى: %1$d ألبومات لكل تحديد. - إضافة إلى قائمة الانتظار وتشغيل - PixelPlayer - مشغل موسيقى - أعلى %1$d - إغلاق - النتيجة - المستوى %1$d - القلوب - اكتمل المستوى! - انتهت اللعبة - النتيجة: %1$d - المحاولة مجدداً؟ - المستوى التالي - إعادة تشغيل اللعبة - اضغط لإعادة الإطلاق - تشغيل موسيقى عشوائية - كسارة الطوب - أعلى نتيجة %1$d - لعب - اسحب لتحريك المضرب - استعادة الوحدات - جاري الاستعادة - استعادة المحدد - تفاصيل النسخة الاحتياطية - تم الإنشاء - إصدار التطبيق - المخطط (Schema) - الجهاز - غير معروف - تم تحديد %1$d من أصل %2$d وحدة - النقل جارٍ الآن… - تحديد الكل - مسح التحديد - %1$d إدخالات · سوف تستبدل البيانات الحالية - بث سحابي - طي المشغل - بث بـ (Cast) - بلوتوث - تشغيل محلي - جاري الاتصال… - قائمة الانتظار - كلمات الأغاني - جلسة بث - جاري الاتصال - متصل - هذا الهاتف - صوت البلوتوث - تشغيل محلي - جاري التشغيل - موقوف مؤقتاً - استعد للاتصال - اسمح لـ PixelPlayer برؤية أجهزتك القريبة وشبكة الـ Wi-Fi الحالية حتى نتمكن من إبقاء البث وصوت البلوتوث ومكبرات الصوت متزامنة. - الأجهزة القريبة - مطلوب لقراءة والتحكم في معدات صوت البلوتوث المتصلة. - الموقع لشبكة الـ Wi‑Fi - يتطلب نظام أندرويد إذن الموقع لمشاركة شبكة الـ Wi-Fi الحالية (SSID) حتى نتمكن من العثور على أجهزة البث المتوافقة. - السماح بالوصول - نحن نستخدم هذه الأذونات فقط لربط الأجهزة — البث، والتحكم في مكبرات الصوت القريبة، وإبقاء الصوت متزامناً. - توصيل الجهاز - جاري الفحص بالقرب منك - عناصر التحكم - الأجهزة - الاتصالية - تشغيل الـ Wi-Fi أو البلوتوث - إدارة الشبكات النشطة وإعادة الفحص - تحديث الاتصالات - تحديث الأجهزة - الأجهزة القريبة - الأجهزة القريبة - مطلوب لاكتشاف والتحكم في أجهزة صوت البلوتوث المتصلة. - اضغط للاتصال - لا توجد أجهزة بعد - إلغاء الاتصال - مستوى صوت الجهاز - مستوى صوت الهاتف - جاري البحث عن أجهزة… - تأكد من أن التلفزيون أو مكبر الصوت قيد التشغيل ومتصل بنفس شبكة الـ Wi‑Fi. - متصل - متاح للاتصال - جاري الاتصال - متاح - مستوى البطارية - مستوى الصوت - Wi-Fi - متوقف - متصل - يعمل - بلوتوث - متصل - يعمل - متوقف - الاتصالات متوقفة - قم بتشغيل الـ Wi‑Fi أو البلوتوث لاكتشاف الأجهزة القريبة - تشغيل الـ Wi‑Fi - فتح البلوتوث - إلغاء الاتصال - جاري الاتصال... - أبرز الميزات - التحسينات - الإصلاحات - ما الجديد - ما الجديد - تمت إضافة - تغيير - تم إصلاح - - دعم Android Auto متاح الآن للتشغيل داخل السيارة. - دعم Wear OS بات نشطاً، بما في ذلك عناصر تحكم أفضل للتشغيل من الساعة إلى الهاتف. - توسيع التكامل السحابي مع تحسينات لـ Telegram و NetEase و QQ Music و Google Drive. - ميزتا "المشغلة حديثاً" واستعادة قائمة الانتظار الدائمة تبقيان جلسة استماعك جاهزة. - تم تضمين ميزات النسخ الاحتياطي والاستعادة v3 وأدوات إدارة الحساب. - أصبحت كلمات الأغاني أكثر ذكاءً مع دعم البحث اليدوي الاحتياطي وتحسينات التخزين. - - - تحديث شامل للأداء عبر بدء التشغيل، المكتبة، قائمة الانتظار، وتفاعلات المشغل. - إعادة تصميم واجهات المشغل، البث، الكلمات، الفنان، والنوع لتوفير استخدام أكثر سلاسة. - أصبحت تدفقات التنقل والبحث أكثر موثوقية مع معالجة أكثر أماناً للمسارات. - تحسين توافق تشغيل الصوت لمزيد من الأجهزة والتنسيقات. - توسيع سير عمل التحديد المتعدد عبر الأغاني والألبومات وقوائم التشغيل. - - - أصبح سلوك قائمة الانتظار والخلط العشوائي أكثر استقراراً وقابلية للتنبؤ. - إصلاح العديد من الحالات النادرة في التشغيل الخلفي وبث الصوت (Casting). - إصلاح مشكلات مؤقت النوم، والتنقل في تبويب الملفات، وحالات توقف فنان الألبوم المفاجئ. - تحسين تحميل الويدجت واستقرار الخدمة لتقليل مشكلات الحرارة والذاكرة. - إصلاحات عامة للأخطاء وتحسينات جمالية لواجهة مستخدم التطبيق. - - - تحديث واجهة المستخدم التعبيرية Material 3 Expressive - معادل صوتي ذو 10 نطاقات وتأثيرات صوتية - تدفق مزامنة جديد للمكتبة الموسيقية - التكامل مع الذكاء الاصطناعي (نماذج Gemini) - استيراد وتصدير قوائم التشغيل بصيغة M3U - تكامل أغلفة الفنانين من منصة Deezer - أغلفة مخصصة لقوائم التشغيل - - - إعادة هيكلة معمارية الإعدادات - رسوم متحركة جديدة لقائمة الانتظار والمشغل - ملفات التعريف الأساسية (Baseline Profiles) وتحسين الأداء - نظام أفضل لكلمات الأغاني مع إزاحة التزامن - - - تحسينات استقرار بث الصوت (Casting) - استقرار لوحة المشغل السفلية - إصلاحات عامة للأخطاء وتنظيف الكود - - - إعادة تصميم كبرى لنظام التنقل - مستكشف ملفات جديد لاختيار مجلدات المصدر - وظائف اتصال وبث جديدة - استمرارية سلسة بين الأجهزة عن بعد - انتقال بدون فجوات (Gapless) بين الأغاني - عنصر التحكم في التلاشي المتبادل (Crossfade) - ميزة الانتقالات المخصصة الجديدة (لقوائم التشغيل فقط) - استمرار التشغيل بعد إغلاق التطبيق - تحسينات واجهة المستخدم - ميزة إحصائيات محسنة - إعادة تصميم التحكم في قائمة الانتظار مع المزيد من الميزات - تحسين دعم أنواع الملفات المختلفة للتشغيل وتعديل البيانات الوصفية - تحسين متحكم الأذونات - إصلاحات طفيفة للأخطاء - - - تقديم مركز إحصائيات استماع أكثر ثراءً مع رؤى عميقة لجلساتك. - إطلاق مشغل سريع عائم لفتح ومعاينة الملفات المحلية على الفور. - إضافة تبويب المجلدات مع مستكشف بنمط شجري وعرض جاهز لقوائم التشغيل. - - - تحسين واجهة Material 3 بالكامل لتوفير تجربة أنظف وأكثر تماسكاً. - تحرير البيانات الوصفية يدعم الآن تغيير غلاف الألبوم. - تنعيم الرسوم المتحركة والانتقالات عبر التطبيق لتنقل أكثر انسيابية. - تحسين تخطيط شاشة الفنان مع تفاصيل أكثر ثراءً ولمسات جمالية. - ترقية توليد DailyMix و YourMix باختيارات أكثر ذكاءً وتنوعاً. - تعزيز توليد قوائم التشغيل بواسطة الذكاء الاصطناعي. - تحسين صلة نتائج البحث وعرضها لاكتشاف أسرع. - توسيع الدعم لنطاق أوسع من تنسيقات الملفات الصوتية. - - - حل مشكلات البيانات الوصفية الغريبة لتبقى تفاصيل الأغاني دقيقة في كل مكان. - استعادة اختصارات الإشعارات لتعود بشكل موثوق إلى شاشة التشغيل. - - - دعم Chromecast لبث الصوت من جهازك. - سجل التغييرات داخل التطبيق لإبقائك على اطلاع بآخر الميزات. - دعم ملفات LRC، سواء كانت مدمجة أو خارجية. - دعم كلمات الأغاني دون اتصال بالإنترنت. - كلمات أغاني متزامنة (متطابقة مع الأغنية). - شاشة جديدة لعرض كامل قائمة الانتظار. - إعادة ترتيب وإزالة الأغاني من قائمة الانتظار. - إيماءات المشغل المصغر (السحب للأسفل للإغلاق). - إضافة المزيد من رسوم Material المتحركة. - إعدادات جديدة لتخصيص المظهر والإحساس العام. - إعدادات جديدة لمسح ذاكرة التخزين المؤقت. - - - إعادة تصميم كاملة لواجهة المستخدم. - إعادة تصميم كاملة للمشغل. - تحسينات الأداء في المكتبة الموسيقية. - تحسين سرعة تشغيل التطبيق عند البدء. - الذكاء الاصطناعي يقدم الآن نتائج أفضل. - - - إصلاح أخطاء مختلفة في محرر العلامات (Tags). - إصلاح مشكلة عدم اختفاء إشعار التشغيل. - إصلاح عدة أخطاء كانت تتسبب في توقف التطبيق فجأة. - - - Wear OS: نقل الموسيقى، التشغيل المحلي، مزامنة قائمة الانتظار، والتحكم عن بعد من الساعة. - الذكاء الاصطناعي: تكامل Groq AI و OpenRouter (تجريبي) مع تحسين استهلاك الرموز (Tokens). - السحاب: إضافة دعم Jellyfin. - كلمات الأغاني: ترجمة متزامنة مع مفتاح تبديل مخصص، دعم تنسيق Kugou LRC، تخصيص محاذاة النص، وتحسين التحميل عن بعد. - واجهة المستخدم/تجربة المستخدم: وضع شريط التنقل المدمج، سمات ديناميكية من لوحة ألوان غلاف الألبوم، نص متحرك (Marquee) للعناوين الطويلة، وخيارات فرز جديدة. - تليجرام: دعم أصلي للمواضيع (Topics) وأنماط عرض محسنة. - - - المحرك الصوتي: إصلاح شامل مع دعم المزيد من التنسيقات (MIDI, ALAC, M4A) وتحسين برنامج فك الترميز. - الكفاءة: تقليل جذري في استهلاك الطاقة، إصلاحات للحرارة الزائدة، وتحسين المهام الخلفية (SyncWorker). - قاعدة البيانات: تحسينات هائلة على الاستعلامات وإعادة تصميم ذاكرة التخزين المؤقت للأغلفة لمنع فقدان البيانات. - بدء التشغيل: تحسين وقت التحميل عبر تهيئة Baseline Profile. - - - التتشغيل: إصلاح التقطع في Opus/MP3، أخطاء ReplayGain أثناء التلاشي المتبادل، ومشكلات بدء التشغيل على مفككات ترميز Samsung. - الاستقرار: القضاء على حالات التوقف المفاجئ عند البدء، وأثناء التنقل بين الفنانين، وعلى أجهزة أندرويد 12+. - واجهة المستخدم: إصلاح وميض الأغلفة، وتداخل النصوص في النصوص غير اللاتينية، وسلوك شريط التنقل/المشغل المصغر. - الأمان: تعزيز التعامل مع بيانات الاعتماد، أذونات التخزين، والاتصال بخادم الوسائط. - - - العربية - Spanish - French - Russian - Simplified Chinese - Indonesian - Italian - diff --git a/app/src/main/res/values-ar/strings_screens.xml b/app/src/main/res/values-ar/strings_screens.xml index 3570cde6d..4dc3d635f 100644 --- a/app/src/main/res/values-ar/strings_screens.xml +++ b/app/src/main/res/values-ar/strings_screens.xml @@ -5,11 +5,8 @@ شكراً لك على استخدام PixelPlayer! - محددات الكلمات الحالية - هذه الكلمات المفتاحية تفصل أسماء الفنانين عندما تكون محاطة بمسافات. يتم مطابقتها دون تفرقة بين الأحرف الكبيرة والصغيرة. اضغط للحذف. لم يتم تهيئة أي محددات كلمات إضافة محدد كلمات جديد - مثال: .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 محددات الكلمات @@ -18,13 +15,9 @@ تمت إضافة محدد الكلمات موجود بالفعل أو غير صالح تمت إعادة تعيين محددات الكلمات إلى الافتراضية - إعادة تعيين - المحددات الحالية - انقر على محدد لإزالته. يلزم وجود محدد واحد على الأقل. إضافة محدد جديد - مثال: / أو ; المحددات الافتراضية إعادة تعيين المحددات؟ سيؤدي هذا إلى مسح جميع المحددات المخصصة واستعادة المحددات الافتراضية. لا يمكن التراجع عن هذا الإجراء. @@ -32,9 +25,7 @@ يلزم وجود محدد واحد على الأقل تمت إضافة المحدد المحدد موجود بالفعل أو غير صالح - المحددات مسافة - إضافة محدد خدمة Google Drive قادمة قريباً. @@ -54,10 +45,6 @@ اختر الطريقة المفضلّة لديك للتنقل في مكتبتك. الوضع المدمج يمكنك تغيير هذا لاحقاً من الإعدادات > المظهر > التنقل في المكتبة. - المكتبة - الأغاني - الألبومات - الفنانون كل شيء جاهز! أنت مستعد الآن للاستمتاع بموسيقاك. استعادة النسخة الاحتياطية @@ -65,31 +52,14 @@ تم تحديد %1$d من أصل %2$d من الوحدات تم الإنشاء في %1$s نسخة احتياطية من الإصدار %1$s - إصدار غير معروف هيا بنا! الخطوة %1$d من أصل %2$d التنقل داخل التطبيق اختر نمط شريط التنقل السفلي. النمط الافتراضي يمكنك تغيير هذا لاحقاً من الإعدادات > المظهر > نمط شريط التنقل. - تخطي في الوقت الحالي - تخطي / ليس الآن - جاري الاستعادة - استعادة المحدد - تخصيص نصف قطر الزوايا - يرجى منح الإذن المطلوب أولاً. - يرجى منح جميع الأذونات المطلوبة. - يرجى منح أذونات التخزين أولاً - تعذر فتح إعدادات البطارية - توسيع القائمة - التالي - إنهاء - إغلاق - إزالة - إضافة محدد كلمات - إعادة تعيين الافتراضيات المجلدات المستبعدة @@ -126,25 +96,16 @@ شريط قياسي بالعرض الكامل - حذف الأغنية؟ - \"%1$s\" بواسطة %2$s\n\nسيتم حذف هذه الأغنية نهائياً من جهازك ولا يمكن استعادتها. المزيج\nالخاص بك لا توجد بيانات لعرضها بعد سيظهر المزيج الخاص بك هنا عندما يجد PixelPlayer أغانٍ أو يقوم بمزامنة أحد المصادر. تحديث - تشغيل عشوائي - غلاف ألبوم لـ %1$s - خيارات ملء سريع للنوع - فنان عام - تشغيل الألبوم - تشغيل الألبوم عشوائياً غلاف %1$s %1$s · %2$s تشغيل/إيقاف مؤقت - غلاف الأغنية عذراً! حدث خطأ ما @@ -153,21 +114,11 @@ الخطأ: تتبع الكومة (معاينة): سجل التعطل - تم نسخ سجل التعطل إلى الحافظة تقرير تعطل PixelPlayer مشاركة تقرير التعطل - نسخ - مشاركة بحث… - بحث - مسح البحث - عمليات البحث الأخيرة - مسح الكل - السجل - حذف عنصر من سجل البحث - لا توجد نتائج لا توجد نتائج لـ \"%1$s\" لم يتم العثور على شيء جرّب مصطلح بحث آخر أو تحقق من الفلاتر الخاصة بك. @@ -183,7 +134,6 @@ مشغل موسيقى مفتوح المصدر تم بناؤه مع مجتمعه. الإصدار v%1$s %1$d مساهمة - حول التطبيق المشرف الرئيسي الشخص الذي يقف وراء PixelPlayer. أضواء على المجتمع @@ -193,60 +143,6 @@ مفتوح المصدر المجتمع أولاً تصميم Material 3 معبر - فتح ملف GitHub الشخصي - فتح Telegram - الصورة الشخصية لـ %1$s - أيقونة %1$s - Subsonic - تم مزامنة %1$d قائمة تشغيل - تم مزامنة %1$d مجلد - قوائم التشغيل - مجلدات الموسيقى - مزامنة - لم يتم مزامنة أي قوائم تشغيل بعد - اضغط على مزامنة لجلب قوائم التشغيل الخاصة بك - اضغط على مزامنة لجلب قوائم تشغيل Jellyfin الخاصة بك - لم يتم إضافة مجلدات بعد - انقر على + لإضافة مجلد من Drive - إجراءات سريعة - إدارة خوادم Navidrome وAirsonic والخوادم الأخرى المتوافقة مع Subsonic. - إدارة اتصال خادم Jellyfin الخاص بك. - جاري المزامنة - مزامنة المكتبة - قطع الاتصال - جاري مزامنة المكتبة… - جاري جلب قوائم التشغيل… - جاري مزامنة قائمة التشغيل: %1$s - جاري تحديث المكتبة المحلية… - اكتملت المزامنة - جاري جلب قائمة الألبومات… - جاري جلب الأغاني من %1$s… - جاري حفظ %1$d أغانٍ في قاعدة البيانات… - لم يتم العثور على أغانٍ في المكتبة - اكتملت مزامنة المكتبة - %1$d أغانٍ - مزامنة - مزامنة الكل - إضافة مجلد - تسجيل الخروج - NetEase Music - QQ Music - مزامنة جميع قوائم التشغيل - خطأ: %1$s - جاري المزامنة… - اختر نوع قائمة التشغيل - اختر قوائم التشغيل المراد مزامنتها: - جميع قوائم التشغيل - المنشأة والمجمعة - قوائم التشغيل المنشأة - قوائم التشغيل المجمعة - الصورة الشخصية للمستخدم - تم إنشاء قائمة التشغيل بنجاح - يرجى تعيين مفتاح API لمزود الذكاء الاصطناعي أولاً - يرجى تعيين مفتاح API لـ Gemini أولاً - تمت الإضافة إلى قائمة الانتظار - سيتم التشغيل تالياً - تعذر مشاركة الأغنية: %1$s diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index afced2d2d..37199673b 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -1,11 +1,6 @@ - الإعدادات - الحسابات - إدارة خدمات Telegram، وGoogle Drive، وNetEase وغيرها - إدارة الموسيقى - إدارة المجلدات، تحديث المكتبة، وخيارات التحليل البرمجي المظهر السمات (الثيمات)، التخطيط، والأنماط المرئية التشغيل @@ -25,278 +20,18 @@ حول التطبيق معلومات التطبيق، الإصدار، والحقوق - مفعل - معطل - مُمكن - مُعطل - فتح - تحديد الكل - مسح التحديد - إغلاق التنويه - بنية المكتبة - المجلدات المستبعدة - سيتم تخطي المجلدات الموجودة هنا أثناء فحص مكتبتك. - الفنانون - خيارات تنظيم وتحليل البيانات للفنانين المتعددين. - التصفية - الحد الأدنى لمدة الأغنية - الحد الأدنى للمسارات في الألبوم - حد ذاكرة التخزين المؤقت لأغلفة الألبومات - الحد الأقصى لحجم الذاكرة التخزينية قبل حذف الصور الأقدم تلقائياً - المزامنة والفحص - إجراء فحص كامل جديد - انتهت مزامنة المكتبة - بدأ الفحص الكامل من جديد… - فحص تلقائي لملفات .lrc - فحص وتعيين ملفات الكلمات المزامنة (.lrc) المتواجدة في نفس المجلد تلقائياً أثناء المزامنة. - إدارة كلمات الأغاني - أولوية مصدر كلمات الأغاني - اختر المصدر الذي يفضله التطبيق أولاً عند جلب الكلمات. - المضمنة أولاً (Embedded) - عبر الإنترنت أولاً - الملف المحلي (.lrc) أولاً - إعادة تعيين الكلمات المستوردة - حذف جميع كلمات الأغاني المستوردة من قاعدة البيانات. - المظهر العام - لغة التطبيق - اختر اللغة المستخدمة في واجهة التطبيق بكاملها. - افتراضية النظام - English (الإنجليزية) - Español (الإسبانية) - Deutsch (الألمانية) - Français (الفرنسية) - Русский (الروسية) - 简体中文 (الصينية المبسطة) - Bahasa Indonesia (الإندونيسية) - Italiano (الإيطالية) - Türkçe (التركية) - مظهر التطبيق - التنقل بين المظهر الفاتح، الداكن، أو تتبع النظام. - المظهر الفاتح - المظهر الداكن - حسب النظام - حواف ناعمة ومرنة - استخدام زوايا وحواف دائرية معقدة لتحسين الجمالية؛ قد يؤثر على الأداء في الأجهزة الضعيفة - تعطيل تأثيرات الضبابية (Blur) - إيقاف تشغيل تأثيرات التغبيش والضبابية لتوفير البطارية وموارد الجهاز. - إظهار شريط التمرير - عرض شريط تمرير جانبي في قوائم الموسيقى للتنقل السريع - شاشة المشغل الحالي - مظهر المشغل - اختر مظهر شريط المشغل العائم. - غلاف الألبوم - ديناميكي حسب النظام - إظهار معلومات ملف الموسيقى - عرض الترميز، ومعدل البت، ومعدل العينة في قسم تقدم المشغل. - نمط لوحة غلاف الألبوم - الحالي: %1$s. افتح المعاينة الحية لتحديد النمط. - نمط العرض الدائري (Carousel) - اختر نمط وطريقة عرض دائرة الألبومات. - بدون إلقاء نظرة خاطفة - إلقاء نظرة خاطفة واحدة - تجميعة الصفحة الرئيسية - نمط التجميعة - اختر ترتيب الأشكال لتجميعة صور "المزيج الخاص بك". - تدوير الأنماط تلقائياً - تغيير أنماط التجميعة بشكل دوري في كل مرة تزور فيها الصفحة الرئيسية. - شريط التنقل - نمط شريط التنقل - اختر مظهر ونمط شريط التنقل السفلي. - الافتراضي - العرض الكامل - الوضع المدمج - إظهار الأيقونات فقط وتقليل ارتفاع شريط التنقل. - نصف قطر زوايا شريط التنقل - ضبط درجة تدوير زوايا شريط التنقل. - شاشة كلمات الأغاني - كلمات غامرة وملء الشاشة - إخفاء عناصر التحكم تلقائياً وتكبير حجم الخط. - مهلة الإخفاء التلقائي - الوقت المستغرق قبل إخفاء عناصر التحكم. - 3 ثوانٍ - 4 ثوانٍ - 5 ثوانٍ - 6 ثوانٍ - التنقل داخل التطبيق - علامة التبويب الافتراضية - اختر علامة التبويب الافتراضية عند تشغيل التطبيق. - الرئيسية - التنقل في المكتبة - اختر طريقة التنقل بين علامات تبويب المكتبة الموسيقية. - صف علامات التبويب (افتراضي) - شريط كبسولة مدمج وشبكة - التشغيل في الخلفية - استمرار التشغيل بعد الإغلاق - إذا تم إيقافه، فإن إزالة التطبيق من التطبيقات الحديثة سيؤدي لإيقاف التشغيل. - تحسين استهلاك البطارية - تعطيل تحسين البطارية لمنع انقطاع التشغيل في الخلفية. - تم تعطيل تحسين البطارية بالفعل - موازنة مستوى الصوت (ReplayGain) - تفعيل ميزة ReplayGain - تثبيت وموازنة مستويات الصوت تلقائياً باستخدام بيانات ReplayGain الوصفية المرفقة بالملفات. - وضع زيادة الكسب (Gain Mode) - مسار: موازنة كل أغنية على حدة. ألبوم: موازنة الصوت لكل ألبوم بالكامل. - مسار - ألبوم - البث (Cast) - التشغيل التلقائي عند الاتصال أو قطع البث - بدء التشغيل مباشرة بعد تبديل اتصالات البث والأجهزة. - سماعات الرأس - استئناف التشغيل عند إعادة توصيل السماعات - إذا توقف التشغيل مؤقتاً بسبب فصل السماعة، فسيتم الاستئناف تلقائياً بمجرد توصيلها مجدداً. - قائمة الانتظار والانتقالات - التداخل (Crossfade) - تفعيل الانتقال السلس والمندمج بين الأغاني المتتالية. - مدة التداخل (Crossfade) - وضع الصوت عالي الدقة (Hi-Fi) - مخرج صوتي بصيغة Float 32-bit. قم بتعطيله إذا لاحظت تقطيعاً في الصوت على جهازك. - غير مدعوم على هذا الجهاز (خاصية PCM_FLOAT AudioTrack غير متوفرة). - التشغيل العشوائي الدائم - تذكر إعداد التشغيل العشوائي والاحتفاظ به حتى بعد إغلاق التطبيق. - إظهار سجل قائمة الانتظار - عرض الأغاني التي تم تشغيلها سابقاً داخل قائمة الانتظار. - المجلدات - إيماءة الرجوع تتحكم بالمجلدات - في تبويب المجلدات، يؤدي الرجوع للتنقل عبر تسلسل المجلدات أولاً قبل الخروج من المكتبة. - إيماءات المشغل - النقر على الخلفية يغلق المشغل - النقر على الخلفية الضبابية يؤدي لإغلاق صفحة المشغل المنبثقة. - المؤثرات اللمسية - الاستجابة اللمسية (الاهتزاز) - تفعيل اهتزازات الاستجابة الخفيفة عبر أرجاء التطبيق. - مزود الذكاء الاصطناعي - المزود - اختر مزود خدمة الذكاء الاصطناعي الخاص بك - وضع الرموز الآمن (Safe Token) - مفعل — سريع وموفر. يرسل بيانات قليلة ومحدودة (~1K رموز) إلى الذكاء الاصطناعي. - معطل — سياق عميق. يرسل ملف الاستماع الكامل بالكامل (~8K رموز) لنتائج أكثر ثراءً ودقة. - بيانات الاعتماد - مفتاح API لـ %1$s - احصل عليه من %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) - اختيار النموذج - جاري تحميل النماذج المتاحة… - نموذج الذكاء الاصطناعي - اختر نموذجاً. - سلوك الأوامر (Prompt) - الأمر البرمجي للنظام - تخصيص وتحديد كيفية تصرف واستجابة الذكاء الاصطناعي. - تقرير استخدام الذكاء الاصطناعي - إجمالي الاستهلاك - تتبع رموز %1$s \nالمدخلات (Prompt): %2$s | المخرجات: %3$s | التفكير: %4$s - إنشاء نسخة احتياطية - تصدير النسخة الاحتياطية - يقوم بإنشاء ملف نسخة احتياطية بصيغة %1$s .pxpl. - استعادة النسخة الاحتياطية - استيراد نسخة احتياطية - تصفح أو اختر من النسخ الاحتياطية الأخيرة. البيانات المحددة ستستبدل البيانات الحالية. - الميزات التجريبية - تجريبي - تجارب ومفاتيح تبديل لتحميل واجهة المستخدم للمشغل. - اختبار تدفق الإعداد الأولية - تشغيل شاشة الإعداد والترحيب المبدئية لأغراض الاختبار. - الصيانة - فرض إعادة توليد المزيج اليومي - إعادة إنشاء قائمة تشغيل المزيج اليومي فوراً. - فرض إعادة توليد الإحصائيات - مسح ذاكرة التخزين المؤقت وإعادة حساب إحصاءات التشغيل بالكامل. - فرض إعادة توليد لوحة ألوان الألبوم - التشخيص والأعطال - إطلاق تعطل تجريبي - محاكاة تعطل مفاجئ للتطبيق لاختبار نظام تقارير الأخطاء. - التطبيق - حول PixelPlayer - إصدار التطبيق، الحقوق والمساهمين، والمزيد. - لم يتم اختيار أي قسم. - تم اختيار جميع الأقسام. - تم اختيار %1$d من أصل %2$d من الأقسام. - كيفية عمل النسخ الاحتياطي - اختر الأقسام، وقم بتصدير ملف بصيغة .pxpl، واستورده لاحقاً. الاستعادة ستقوم فقط باستبدال الأقسام التي تحددها بنفسك. - اختر بدقة ما ترغب في تضمينه داخل حزمة النسخ الاحتياطي. - تصدير ملف .pxpl - تم تحديد %1$d من أصل %2$d أقسام - عملية النقل جارية… - جاري التصدير - جاري الاستيراد - جاري إنشاء النسخة الاحتياطية - جاري استعادة النسخة الاحتياطية - الخطوة %1$d من أصل %2$d - إدخالات عدد %1$d · ستستبدل البيانات الحالية بالكامل - تم إعادة توليد لوحة الألوان لـ %1$s - تعذر إعادة توليد لوحة الألوان لـ %1$s - جاري إعادة توليد لوحات ألوان الألبومات… - إعادة توليد جميع لوحات ألوان الألبومات؟ - إعادة بناء متغيرات الألوان المخزنة مؤقتاً لـ %1$d من أغلفة الألبومات الفريدة. قد يستغرق هذا الإجراء بعض الوقت في المكتبات الكبيرة. - سيؤدي هذا إلى مسح بيانات السمات المخزنة مؤقتاً وإعادة بناء جميع أنماط لوحات الألوان لـ %1$d من أغلفة الألبومات الفريدة. - اكتمل %1$d من أصل %2$d - جاري العمل… - إعادة التوليد - تم إعادة توليد %1$d من لوحات ألوان الألبومات - تم إعادة توليد %1$d من أصل %2$d من لوحات ألوان الألبومات - إعادة تعيين كلمات الأغاني المستوردة؟ - لا يمكن التراجع عن هذا الإجراء لاحقاً. - تأكيد - إعادة بناء قاعدة البيانات؟ - سيؤدي هذا إلى إعادة بناء مكتبة الموسيقى الخاصة بك بالكامل من الصفر. ستفقد جميع كلمات الأغاني المستوردة، والمفضلة، والبيانات الوصفية المخصصة. لا يمكن التراجع عن هذا الإجراء. - إعادة البناء - جاري إعادة بناء قاعدة البيانات - جاري إعادة بناء قاعدة البيانات… - إعادة توليد المزيج اليومي؟ - سيؤدي هذا إلى تجاهل المزيج الحالي وإنشاء مزيج جديد بناءً على عادات الاستماع الأخيرة. - بدأت عملية إعادة توليد المزيج اليومي - إعادة توليد الإحصائيات؟ - سيؤدي هذا إلى مسح ذاكرة التخزين المؤقت للإحصاءات وفرض إعادة الحساب من سجل قاعدة البيانات. - بدأت عملية إعادة توليد الإحصائيات - PixelPlayer_Backup_%1$d.pxpl - إعادة توليد المزيج اليومي - إعادة توليد الإحصائيات - لم يتم العثور على أي أغانٍ تحتوي على غلاف ألبوم. - إعادة بناء جميع متغيرات لوحة الألوان المخزنة مؤقتاً لكل غلاف ألبوم، أو اختر أغنية واحدة لتحديثها. - إعادة توليد الكل - جاري إعادة التوليد… - اختر الأغنية - مسح السجلات - سجل نشاط الذكاء الاصطناعي (%1$d) - إظهار - إخفاء - تحديد وتصدير - تحديد واستعادة - استيراد نسخة احتياطية - جاري الفحص المعمق… - تصفح لاختيار الملف - اختر ملف نسخة احتياطية بصيغة .pxpl لفحصه. ستتمكن من اختيار الأقسام المراد استعادتها في الخطوة التالية. - النسخ الاحتياطية الأخيرة - لا توجد نسخ احتياطية حديثة - النسخ الاحتياطية التي تم استيرادها سابقاً ستظهر هنا. - فرض إعادة توليد لوحة ألوان الألبوم - اختر أغنية لمسح بيانات المظهر المخزنة مؤقتاً وإعادة توليد جميع أنماط الألوان من غلاف الألبوم. - البحث حسب العنوان، أو الفنان، أو الألبوم - جاري إعادة توليد لوحة الألوان… - لا توجد أغانٍ تطابق بحثك. - إزالة من السجل - مسح البحث - وحدات عدد %1$d · إصدار %2$s · إصدار المخطط البرمجي %3$d - Korean (الكورية) - Norwegian (النرويجية بوكمول) GitHub مستودع الكود تليجرام From 4dbec45cfd129089a0deec383f1640079045ebd6 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 19:39:26 +0200 Subject: [PATCH 18/21] chore(i18n): remove stale Arabic plurals.xml with renamed/deleted keys --- app/src/main/res/values-ar/plurals.xml | 75 -------------------------- 1 file changed, 75 deletions(-) delete mode 100644 app/src/main/res/values-ar/plurals.xml diff --git a/app/src/main/res/values-ar/plurals.xml b/app/src/main/res/values-ar/plurals.xml deleted file mode 100644 index 038673ef3..000000000 --- a/app/src/main/res/values-ar/plurals.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - لا يتم مشاركة أي قوائم تشغيل - جاري مشاركة قائمة تشغيل واحدة - جاري مشاركة قائمتي تشغيل - جاري مشاركة %d قوائم تشغيل - جاري مشاركة %d قائمة تشغيل - جاري مشاركة %d قائمة تشغيل - - - لم يتم تصدير أي قوائم تشغيل إلى %2$s - تم تصدير قائمة تشغيل واحدة إلى %2$s - تم تصدير قائمتي تشغيل إلى %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 مساراً - - From 783495570af602c29f476ca7f91d6d716df31885 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 20:38:26 +0200 Subject: [PATCH 19/21] fix: resolve easy Kotlin compiler warnings - Remove unnecessary safe calls on non-null receivers - Remove redundant casts covered by smart cast - Add FlowPreview opt-in to MusicRepositoryImpl - Remove always-true Elvis operators in GenreDetailViewModel --- .../pixelplay/data/ai/AiPlaylistGenerator.kt | 2 +- .../repository/MediaStoreSongRepository.kt | 2 +- .../data/repository/MusicRepositoryImpl.kt | 3 ++- .../presentation/screens/LibraryMediaTabs.kt | 4 ++-- .../presentation/screens/LibrarySongsTab.kt | 2 +- .../viewmodel/AccountsViewModel.kt | 12 ++++++------ .../viewmodel/ConnectivityStateHolder.kt | 2 +- .../viewmodel/GenreDetailViewModel.kt | 18 +++++++++--------- 8 files changed, 23 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt index 06b91dce4..ab3c891b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/AiPlaylistGenerator.kt @@ -54,7 +54,7 @@ class AiPlaylistGenerator @Inject constructor( val genre = song.genre?.replace("\"", "'")?.take(15) ?: "?" if (index > 0) append(",\n") if (useExtendedFields) { - val album = song.album?.replace("\"", "'")?.take(25) ?: "?" + val album = song.album.replace("\"", "'")?.take(25) ?: "?" val dur = song.duration val fav = if (song.isFavorite) "1" else "0" append("""{"id":"${song.id}","t":"$title","a":"$artist","g":"$genre","al":"$album","d":$dur,"f":$fav,"s":$score}""") diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MediaStoreSongRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MediaStoreSongRepository.kt index 735a18c45..0367a785b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MediaStoreSongRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MediaStoreSongRepository.kt @@ -382,7 +382,7 @@ class MediaStoreSongRepository @Inject constructor( val minDuration = values[3] as Int Triple(allowedDirs, blockedDirs, minDuration) }.flatMapLatest { (allowedDirs, blockedDirs, minDuration) -> - val minDurationMs = minDuration as Int + val minDurationMs = minDuration val musicIds = getFilteredSongIds(allowedDirs.toList(), blockedDirs.toList(), minDurationMs) val genreMap = getSongIdToGenreMap(context.contentResolver) 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 f6dca6ea7..385478c4b 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 @@ -80,6 +80,7 @@ import androidx.paging.map import androidx.paging.filter import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.channels.BufferOverflow @@ -88,7 +89,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.CoroutineScope -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Singleton class MusicRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt index 3245ea965..39b5d6a65 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt @@ -220,7 +220,7 @@ fun LibraryAlbumsTab( when { refreshState is LoadState.Error && albums.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() @@ -531,7 +531,7 @@ fun LibraryArtistsTab( when { refreshState is LoadState.Error && artists.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt index 5fdab7ddb..e09767b0b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt @@ -222,7 +222,7 @@ fun LibrarySongsTab( when { refreshState is LoadState.Error && songs.itemCount == 0 -> { - val error = (refreshState as LoadState.Error).error + val error = refreshState.error Box( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt index 354d92987..1f2ee30e6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt @@ -114,12 +114,12 @@ class AccountsViewModel @Inject constructor( ) { it.toList() }, loggingOutServices ) { states, activeLogouts -> - val (telegramConnected, telegramChannelCount) = states[0] as Pair - val (gDriveConnected, gDriveFolderCount) = states[1] as Pair - val (neteaseConnected, neteasePlaylistCount) = states[2] as Pair - val (qqConnected, qqPlaylistCount) = states[3] as Pair - val (navidromeConnected, navidromePlaylistCount) = states[4] as Pair - val (jellyfinConnected, jellyfinPlaylistCount) = states[5] as Pair + val (telegramConnected, telegramChannelCount) = states[0] + val (gDriveConnected, gDriveFolderCount) = states[1] + val (neteaseConnected, neteasePlaylistCount) = states[2] + val (qqConnected, qqPlaylistCount) = states[3] + val (navidromeConnected, navidromePlaylistCount) = states[4] + val (jellyfinConnected, jellyfinPlaylistCount) = states[5] val connectedAccounts = buildList { if (telegramConnected) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt index f86f46be3..e45858d72 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt @@ -329,7 +329,7 @@ class ConnectivityStateHolder @Inject constructor( device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || device.type == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO ) { - val name = device.productName?.toString()?.trim().orEmpty() + val name = device.productName.toString().trim() if (name.isNotEmpty() && !isOwnBluetoothDeviceName(name, localDeviceNames)) { val address = device.address?.trim().orEmpty().takeIf { it.isNotEmpty() } val key = bluetoothDeviceKey(address, name) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt index 3c5c821e6..82ab8489a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/GenreDetailViewModel.kt @@ -158,7 +158,7 @@ class GenreDetailViewModel @Inject constructor( val sections = buildDisplaySections(songs, SortOption.ARTIST) val flattened = flattenSections(sections, artistMap) - val sorted = songs.sortedBy { it.artist ?: "Unknown Artist" } + val sorted = songs.sortedBy { it.artist } ProcessingResult(genre, songs, sorted, sections, flattened) } @@ -195,8 +195,8 @@ class GenreDetailViewModel @Inject constructor( val sections = buildDisplaySections(currentState.songs, newSort) val flattened = flattenSections(sections, artistMap) val sorted = when (newSort) { - SortOption.ARTIST -> currentState.songs.sortedBy { it.artist ?: "Unknown Artist" } - SortOption.ALBUM -> currentState.songs.sortedBy { it.album ?: "Unknown Album" } + SortOption.ARTIST -> currentState.songs.sortedBy { it.artist } + SortOption.ALBUM -> currentState.songs.sortedBy { it.album } SortOption.TITLE -> currentState.songs.sortedBy { it.title } } Triple(sections, flattened, sorted) @@ -278,10 +278,10 @@ class GenreDetailViewModel @Inject constructor( private fun buildDisplaySections(songs: List, sort: SortOption): List { return when (sort) { SortOption.ARTIST -> { - val sorted = songs.sortedBy { it.artist ?: "Unknown Artist" } - val grouped = sorted.groupBy { it.artist ?: "Unknown Artist" } - grouped.map { (artist, artistSongs) -> - val albums = artistSongs.groupBy { it.album ?: "Unknown Album" }.map { (albumName, albumSongs) -> + val sorted = songs.sortedBy { it.artist } + val grouped = sorted.groupBy { it.artist } + grouped.map { (artist: String, artistSongs) -> + val albums = artistSongs.groupBy { it.album }.map { (albumName: String, albumSongs) -> val sortedAlbumSongs = albumSongs.sortedWith( compareBy { it.discNumber ?: 1 } .thenBy { if (it.trackNumber > 0) it.trackNumber else Int.MAX_VALUE } @@ -293,8 +293,8 @@ class GenreDetailViewModel @Inject constructor( } } SortOption.ALBUM -> { - val sorted = songs.sortedBy { it.album ?: "Unknown Album" } - val grouped = sorted.groupBy { it.album ?: "Unknown Album" } + val sorted = songs.sortedBy { it.album } + val grouped = sorted.groupBy { it.album } grouped.map { (album, albumSongs) -> val sortedAlbumSongs = albumSongs.sortedWith( compareBy { it.discNumber ?: 1 } From 6cb912a0a4ed4261a937b5b5d746f3700828ee07 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 21:32:12 +0200 Subject: [PATCH 20/21] feat(telegram): add progress UI for large channel and forum syncs Introduces visual progress reporting across dashboard and search flows for large channel syncs, replacing silent 5+ minute loading spinners. Repository: Adds getApproxAudioMessageCount using defensive reflection for TDLib build safety. Adds onProgress callbacks to flat channel and forum topic fetches, and optimizes forum loop reflection. ViewModels: Wires progress tracking into flat/forum sync loops across both dashboard and search view models via a new shared TelegramSyncProgress state. UI (Screen & Sheet): Implements LinearWavyProgressIndicator. Displays a determinate percentage for flat channels (threshold >= 5000) and an indeterminate running count for forum topics where totals are unavailable. --- .../data/telegram/TelegramRepository.kt | 72 ++++++++++------- .../channel/TelegramChannelSearchSheet.kt | 43 ++++++++++ .../channel/TelegramChannelSearchViewModel.kt | 18 ++++- .../dashboard/TelegramDashboardScreen.kt | 81 +++++++++++++++++++ .../dashboard/TelegramDashboardViewModel.kt | 23 +++++- 5 files changed, 202 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt index 4a3a31615..1817a7cc2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt @@ -1,5 +1,13 @@ package com.theveloper.pixelplay.data.telegram +/** + * Progress of an in-flight getAudioMessages fetch. current = songs fetched so far, + * approxTotal = -1 if unknown, otherwise TDLib's approximate count for the channel (see + * TelegramRepository.getApproxAudioMessageCount). Shared by every call site that wants to + * show fetch progress, rather than each declaring its own copy of the same shape. + */ +data class TelegramSyncProgress(val current: Int, val approxTotal: Int) + import com.theveloper.pixelplay.data.database.TelegramDao import com.theveloper.pixelplay.data.database.TelegramSongEntity import com.theveloper.pixelplay.data.database.TelegramTopicEntity @@ -267,7 +275,11 @@ class TelegramRepository @Inject constructor( return topics } - suspend fun getAudioMessagesByTopic(chatId: Long, threadId: Long): List { + suspend fun getAudioMessagesByTopic( + chatId: Long, + threadId: Long, + onProgress: (suspend (current: Int) -> Unit)? = null + ): List { Timber.d("Fetching audio for topic threadId=$threadId in chat=$chatId") try { clientManager.sendRequest(TdApi.OpenChat(chatId)) @@ -279,6 +291,24 @@ class TelegramRepository @Inject constructor( var nextFromMessageId = 0L val batchSize = 100 + // Resolved once, outside the loop, since the result never changes between batches — + // previously this reflection lookup (plus a Timber.d field dump) re-ran on every + // single batch despite always resolving the same way for a given build. + var topicFieldName: String? = null + var topicFieldSetsMessageTopic = false + try { + TdApi.SearchChatMessages::class.java.getDeclaredField("topicId") + topicFieldName = "topicId" + topicFieldSetsMessageTopic = true + } catch (_: NoSuchFieldException) { + try { + TdApi.SearchChatMessages::class.java.getDeclaredField("messageThreadId") + topicFieldName = "messageThreadId" + } catch (_: NoSuchFieldException) { + Timber.e("SearchChatMessages: could not resolve a topic filter field — results will be unfiltered") + } + } + try { while (true) { val request = TdApi.SearchChatMessages().apply { @@ -290,37 +320,15 @@ class TelegramRepository @Inject constructor( this.limit = batchSize this.filter = TdApi.SearchMessagesFilterAudio() - // Set the topic/thread filter via reflection to handle different TDLib builds. - // In newer builds the field is 'topicId' (MessageTopic object). - // In older builds it was 'messageThreadId' (Long). - val scFields = this.javaClass.declaredFields - Timber.d("SearchChatMessages fields: ${scFields.map { "${it.name}:${it.type.simpleName}" }}") - - var topicSet = false - - // Try 'topicId' field (newer TDLib — expects a MessageTopic object) - try { - val f = this.javaClass.getDeclaredField("topicId") + if (topicFieldName != null) { + val f = this.javaClass.getDeclaredField(topicFieldName) f.isAccessible = true - // MessageTopicForum wraps the thread ID as Int - f.set(this, TdApi.MessageTopicForum(threadId.toInt())) - Timber.d("SearchChatMessages: set topicId = MessageTopicForum($threadId)") - topicSet = true - } catch (_: NoSuchFieldException) { } - - // Fallback: try 'messageThreadId' field (older TDLib — Long) - if (!topicSet) { - try { - val f = this.javaClass.getDeclaredField("messageThreadId") - f.isAccessible = true + if (topicFieldSetsMessageTopic) { + // MessageTopicForum wraps the thread ID as Int + f.set(this, TdApi.MessageTopicForum(threadId.toInt())) + } else { f.set(this, threadId) - Timber.d("SearchChatMessages: set messageThreadId = $threadId") - topicSet = true - } catch (_: NoSuchFieldException) { } - } - - if (!topicSet) { - Timber.e("SearchChatMessages: could not set topic filter — results will be unfiltered") + } } } @@ -332,6 +340,8 @@ class TelegramRepository @Inject constructor( mapMessageToSong(message)?.let { allSongs.add(it) } } + onProgress?.invoke(allSongs.size) + nextFromMessageId = response.nextFromMessageId if (nextFromMessageId == 0L) break } @@ -373,7 +383,7 @@ class TelegramRepository @Inject constructor( suspend fun getApproxAudioMessageCount(chatId: Long): Int { return try { val result = clientManager.sendRequest( - TdApi.GetChatMessageCount(chatId, null, TdApi.SearchMessagesFilterAudio(), false) + TdApi.GetChatMessageCount(chatId, TdApi.SearchMessagesFilterAudio(), false) ) extractApproxCount(result) } catch (e: Exception) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt index 8ae08a6c4..ddfc39d3d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchSheet.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumExtendedFloatingActionButton import androidx.compose.material3.ModalBottomSheet @@ -73,6 +74,7 @@ fun TelegramChannelSearchSheet( val foundChat by viewModel.foundChat.collectAsStateWithLifecycle() val songs by viewModel.songs.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val syncProgress by viewModel.syncProgress.collectAsStateWithLifecycle() val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -240,6 +242,47 @@ fun TelegramChannelSearchSheet( fontFamily = GoogleSansRounded, color = MaterialTheme.colorScheme.primary ) + // Two cases here, same as the dashboard sync UI: + // - Known total >= 5000 (flat channel): determinate bar with + // a percentage. + // - Unknown total (forum topic fetch, approxTotal == -1): no + // honest percentage available (see TelegramSyncProgress), + // shown as an indeterminate bar with the running count + // instead. Below 5000 with a known total, the indeterminate + // LoadingIndicator above is enough since the fetch finishes + // quickly anyway. + val progress = syncProgress + if (progress != null && (progress.approxTotal >= 5000 || progress.approxTotal == -1)) { + Spacer(modifier = Modifier.height(16.dp)) + if (progress.approxTotal > 0) { + val fraction = (progress.current.toFloat() / progress.approxTotal.toFloat()) + .coerceIn(0f, 1f) + LinearWavyProgressIndicator( + progress = { fraction }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${progress.current} / ${progress.approxTotal}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LinearWavyProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${progress.current} songs so far", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } statusMessage != null -> { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchViewModel.kt index df47f1eed..25d744a85 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/channel/TelegramChannelSearchViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.telegram.TelegramRepository +import com.theveloper.pixelplay.data.telegram.TelegramSyncProgress import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -40,6 +41,10 @@ class TelegramChannelSearchViewModel @Inject constructor( private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() + // See TelegramSyncProgress. Null when no fetch is in progress. + private val _syncProgress = MutableStateFlow(null) + val syncProgress = _syncProgress.asStateFlow() + // Status message for errors or "Not Found" private val _statusMessage = MutableStateFlow(null) val statusMessage = _statusMessage.asStateFlow() @@ -97,7 +102,9 @@ class TelegramChannelSearchViewModel @Inject constructor( val isForum = telegramRepository.isForum(chatId) val chat = _foundChat.value ?: return@launch - val allSongs = telegramRepository.getAudioMessages(chatId) + val allSongs = telegramRepository.getAudioMessages(chatId) { current, approxTotal -> + _syncProgress.value = TelegramSyncProgress(current, approxTotal) + } musicRepository.replaceTelegramSongsForChannel(chatId, allSongs) var localPhotoPath: String? = null @@ -124,7 +131,12 @@ class TelegramChannelSearchViewModel @Inject constructor( musicRepository.replaceTopicsForChannel(chatId, topics) var totalSongs = 0 topics.forEach { topic -> - val topicSongs = telegramRepository.getAudioMessagesByTopic(chatId, topic.threadId) + _statusMessage.value = "Syncing topic \"${topic.name}\"..." + val topicSongs = telegramRepository.getAudioMessagesByTopic(chatId, topic.threadId) { current -> + // approxTotal = -1: no honest per-topic total available, see + // the equivalent comment in TelegramDashboardViewModel. + _syncProgress.value = TelegramSyncProgress(current, -1) + } totalSongs += topicSongs.size musicRepository.replaceTelegramSongsForTopic( chatId = chatId, @@ -158,6 +170,7 @@ class TelegramChannelSearchViewModel @Inject constructor( runCatching { musicRepository.requestTelegramUnifiedSync() } _songs.value = emptyList() _isLoading.value = false + _syncProgress.value = null } } } @@ -189,6 +202,7 @@ class TelegramChannelSearchViewModel @Inject constructor( _foundChat.value = null _songs.value = emptyList() _isLoading.value = false + _syncProgress.value = null _statusMessage.value = null _resolvedUsername.value = null } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt index ebf271854..cdcea9ffc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumExtendedFloatingActionButton import androidx.compose.material3.ModalBottomSheet @@ -94,6 +95,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil.compose.AsyncImage import com.theveloper.pixelplay.data.database.TelegramChannelEntity import com.theveloper.pixelplay.data.database.TelegramTopicEntity +import com.theveloper.pixelplay.data.telegram.TelegramSyncProgress import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.NoInternetScreen import com.theveloper.pixelplay.ui.theme.GoogleSansRounded @@ -114,6 +116,7 @@ fun TelegramDashboardScreen( ) { val channels by viewModel.channels.collectAsStateWithLifecycle() val isRefreshingId by viewModel.isRefreshing.collectAsStateWithLifecycle() + val syncProgress by viewModel.syncProgress.collectAsStateWithLifecycle() val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() val topicsMap by viewModel.topicsMap.collectAsStateWithLifecycle() @@ -240,6 +243,7 @@ fun TelegramDashboardScreen( ExpressiveChannelItem( channel = channel, isSyncing = isRefreshingId == channel.chatId, + syncProgress = syncProgress?.takeIf { isRefreshingId == channel.chatId }, topics = channelTopics, isExpanded = isExpanded, onSync = { viewModel.refreshChannel(channel) }, @@ -312,6 +316,7 @@ fun TelegramDashboardScreen( ChannelActionsBottomSheet( channel = selectedChannel, isSyncing = isRefreshingId == selectedChannel.chatId, + syncProgress = syncProgress?.takeIf { isRefreshingId == selectedChannel.chatId }, onDismiss = { selectedChannelForActions = null }, onSync = { selectedChannelForActions = null @@ -383,6 +388,7 @@ fun TelegramDashboardScreen( private fun ExpressiveChannelItem( channel: TelegramChannelEntity, isSyncing: Boolean, + syncProgress: TelegramSyncProgress?, topics: List, isExpanded: Boolean, onSync: () -> Unit, @@ -591,6 +597,47 @@ private fun ExpressiveChannelItem( } } + // ── Sync progress bar. Two cases: + // - Known total >= 5000 (flat channel fetch): determinate bar with a percentage, + // worth more here than the small indeterminate spinner already in the sync + // button above. Below that threshold the spinner alone is enough. + // - Unknown total (forum topic fetch, approxTotal == -1): no honest percentage + // is available (see TelegramSyncProgress), so this shows an indeterminate bar + // with the running count instead of fabricating a fraction. + AnimatedVisibility( + visible = isSyncing && syncProgress != null && + (syncProgress.approxTotal >= 5000 || syncProgress.approxTotal == -1), + enter = expandVertically(), + exit = shrinkVertically() + ) { + if (syncProgress != null) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) { + if (syncProgress.approxTotal > 0) { + val fraction = (syncProgress.current.toFloat() / syncProgress.approxTotal.toFloat()) + .coerceIn(0f, 1f) + LinearWavyProgressIndicator( + progress = { fraction }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${syncProgress.current} / ${syncProgress.approxTotal}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${syncProgress.current} songs so far", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + // ── Expandable topics list ────────────────────────────────── AnimatedVisibility( visible = isExpanded && topics.isNotEmpty(), @@ -708,6 +755,7 @@ private fun TopicRow(topic: TelegramTopicEntity) { private fun ChannelActionsBottomSheet( channel: TelegramChannelEntity, isSyncing: Boolean, + syncProgress: TelegramSyncProgress?, onDismiss: () -> Unit, onSync: () -> Unit, onDelete: () -> Unit @@ -779,6 +827,39 @@ private fun ChannelActionsBottomSheet( null } ) + AnimatedVisibility( + visible = isSyncing && syncProgress != null && + (syncProgress.approxTotal >= 5000 || syncProgress.approxTotal == -1), + enter = expandVertically(), + exit = shrinkVertically() + ) { + if (syncProgress != null) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) { + if (syncProgress.approxTotal > 0) { + val fraction = (syncProgress.current.toFloat() / syncProgress.approxTotal.toFloat()) + .coerceIn(0f, 1f) + LinearWavyProgressIndicator( + progress = { fraction }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${syncProgress.current} / ${syncProgress.approxTotal}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${syncProgress.current} songs so far", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } Spacer(modifier = Modifier.height(14.dp)) ChannelActionCard( title = stringResource(R.string.telegram_remove_channel), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardViewModel.kt index 65e42abe3..d5eaa4ade 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/telegram/dashboard/TelegramDashboardViewModel.kt @@ -7,6 +7,7 @@ import com.theveloper.pixelplay.data.database.TelegramTopicEntity import com.theveloper.pixelplay.data.database.toTelegramEntityWithThread import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.telegram.TelegramRepository +import com.theveloper.pixelplay.data.telegram.TelegramSyncProgress import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -32,6 +33,13 @@ class TelegramDashboardViewModel @Inject constructor( private val _isRefreshing = MutableStateFlow(null) val isRefreshing = _isRefreshing.asStateFlow() + // current = songs fetched so far, approxTotal = -1 if unknown, otherwise TDLib's + // approximate count for the channel (see TelegramRepository.getApproxAudioMessageCount). + // Null when no fetch is in progress. The UI decides the >= 5000 threshold for whether to + // show a determinate progress bar instead of an indeterminate spinner — this is just data. + private val _syncProgress = MutableStateFlow(null) + val syncProgress = _syncProgress.asStateFlow() + private val _statusMessage = MutableStateFlow(null) val statusMessage = _statusMessage.asStateFlow() @@ -74,12 +82,15 @@ class TelegramDashboardViewModel @Inject constructor( // flight (in particular, never disturbs a full/rebuild). runCatching { musicRepository.requestTelegramUnifiedSync() } _isRefreshing.value = null + _syncProgress.value = null } } } private suspend fun syncFlatChannel(channel: TelegramChannelEntity) { - val songs = telegramRepository.getAudioMessages(channel.chatId) + val songs = telegramRepository.getAudioMessages(channel.chatId) { current, approxTotal -> + _syncProgress.value = TelegramSyncProgress(current, approxTotal) + } musicRepository.replaceTelegramSongsForChannel(channel.chatId, songs) val updatedChannel = channel.copy( @@ -111,7 +122,15 @@ class TelegramDashboardViewModel @Inject constructor( var totalSongs = 0 topics.forEach { topic -> - val topicSongs = telegramRepository.getAudioMessagesByTopic(channel.chatId, topic.threadId) + _statusMessage.value = "Syncing topic \"${topic.name}\"..." + val topicSongs = telegramRepository.getAudioMessagesByTopic(channel.chatId, topic.threadId) { current -> + // approxTotal = -1: GetChatMessageCount has no topic/thread parameter in any + // signature found, so there's no honest way to get a per-topic total the way + // getApproxAudioMessageCount does for a whole chat. Reporting a running count + // without a percentage here, rather than fabricating a total against the whole + // chat's count, which would overestimate badly for any single topic. + _syncProgress.value = TelegramSyncProgress(current, -1) + } totalSongs += topicSongs.size // Replace songs for this topic (with thread_id stamped) From cfb40c88ab2af244e36b78412cccd70630f3504b Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 28 Jun 2026 22:26:14 +0200 Subject: [PATCH 21/21] fix(telegram): fix TelegramSyncProgress placement and GetChatMessageCount signature - Move TelegramSyncProgress data class to after imports (was between package declaration and imports, causing syntax error) - Add missing null messageTopic param to GetChatMessageCount call --- .../data/telegram/TelegramRepository.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt index 1817a7cc2..395798d8a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramRepository.kt @@ -1,12 +1,5 @@ package com.theveloper.pixelplay.data.telegram -/** - * Progress of an in-flight getAudioMessages fetch. current = songs fetched so far, - * approxTotal = -1 if unknown, otherwise TDLib's approximate count for the channel (see - * TelegramRepository.getApproxAudioMessageCount). Shared by every call site that wants to - * show fetch progress, rather than each declaring its own copy of the same shape. - */ -data class TelegramSyncProgress(val current: Int, val approxTotal: Int) import com.theveloper.pixelplay.data.database.TelegramDao import com.theveloper.pixelplay.data.database.TelegramSongEntity @@ -42,6 +35,14 @@ import kotlin.math.absoluteValue import timber.log.Timber +/** + * Progress of an in-flight getAudioMessages fetch. current = songs fetched so far, + * approxTotal = -1 if unknown, otherwise TDLib's approximate count for the channel (see + * TelegramRepository.getApproxAudioMessageCount). Shared by every call site that wants to + * show fetch progress, rather than each declaring its own copy of the same shape. + */ +data class TelegramSyncProgress(val current: Int, val approxTotal: Int) + @Singleton class TelegramRepository @Inject constructor( private val clientManager: TelegramClientManager, @@ -383,7 +384,7 @@ class TelegramRepository @Inject constructor( suspend fun getApproxAudioMessageCount(chatId: Long): Int { return try { val result = clientManager.sendRequest( - TdApi.GetChatMessageCount(chatId, TdApi.SearchMessagesFilterAudio(), false) + TdApi.GetChatMessageCount(chatId, null, TdApi.SearchMessagesFilterAudio(), false) ) extractApproxCount(result) } catch (e: Exception) {