From 76642782f113172cc13d1d9d15614f0c9e44d98d Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 19 Jun 2026 13:44:01 +0200 Subject: [PATCH 1/4] feat(playback): add pause-on-volume-zero setting - Add PAUSE_ON_VOLUME_ZERO DataStore key + flow + setter to UserPreferencesRepository - Collect pref in MusicService and pause playback when volume hits 0 - ReplayGain programmatic volume adjustments are unaffected - Add Python script to inject localized strings into all 12 locale files - Add ContentObserver on Settings.System.CONTENT_URI to detect hardware volume key changes and pause when media stream reaches 0 - Registers observer in onCreate, unregisters in onDestroy --- app/src/debug/res/values-de/strings.xml | 2 + app/src/debug/res/values-fr/strings.xml | 2 + app/src/debug/res/values-ko/strings.xml | 2 + app/src/debug/res/values-nb/strings.xml | 2 + app/src/debug/res/values-ru/strings.xml | 2 + app/src/debug/res/values/strings.xml | 2 + .../preferences/UserPreferencesRepository.kt | 14 ++++++ .../pixelplay/data/service/MusicService.kt | 50 +++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-in/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-nb/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 20 files changed, 100 insertions(+) diff --git a/app/src/debug/res/values-de/strings.xml b/app/src/debug/res/values-de/strings.xml index 196c1e20f..9c5a1e3a8 100644 --- a/app/src/debug/res/values-de/strings.xml +++ b/app/src/debug/res/values-de/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + Pausieren, wenn Lautstärke null erreicht + Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird diff --git a/app/src/debug/res/values-fr/strings.xml b/app/src/debug/res/values-fr/strings.xml index 196c1e20f..e915e2f10 100644 --- a/app/src/debug/res/values-fr/strings.xml +++ b/app/src/debug/res/values-fr/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + Mettre en pause quand le volume atteint zéro + Mettre automatiquement en pause la lecture lorsque le volume est à 0 diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml index 196c1e20f..5081f3546 100644 --- a/app/src/debug/res/values-ko/strings.xml +++ b/app/src/debug/res/values-ko/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + 볼륨이 0이 되면 일시정지 + 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 diff --git a/app/src/debug/res/values-nb/strings.xml b/app/src/debug/res/values-nb/strings.xml index 196c1e20f..586f91ab2 100644 --- a/app/src/debug/res/values-nb/strings.xml +++ b/app/src/debug/res/values-nb/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + Sett på pause når volumet er null + Sett automatisk avspillingen på pause når volumet settes til 0 diff --git a/app/src/debug/res/values-ru/strings.xml b/app/src/debug/res/values-ru/strings.xml index 196c1e20f..8f0a2f8cc 100644 --- a/app/src/debug/res/values-ru/strings.xml +++ b/app/src/debug/res/values-ru/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + Пауза при нулевой громкости + Автоматически приостанавливать воспроизведение, когда громкость равна 0 diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 196c1e20f..70e489601 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,6 @@ PixelPlayer [D] + Pause when volume reaches zero + Automatically pause playback when the volume is set to 0 diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 9efca552a..df556bf04 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -244,6 +244,7 @@ class UserPreferencesRepository @Inject constructor( // ReplayGain val REPLAYGAIN_ENABLED = booleanPreferencesKey("replaygain_enabled") val REPLAYGAIN_USE_ALBUM_GAIN = booleanPreferencesKey("replaygain_use_album_gain") + val PAUSE_ON_VOLUME_ZERO = booleanPreferencesKey("pause_on_volume_zero") val SHOW_SCROLLBAR = booleanPreferencesKey("show_scrollbar") } @@ -745,6 +746,19 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { } } + // ─── Pause on volume zero ───────────────────────────────────────────────── + + val pauseOnVolumeZeroFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] ?: false + } + + suspend fun setPauseOnVolumeZero(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] = enabled + } + } + val showScrollbarFlow: Flow = dataStore.data.map { preferences -> preferences[PreferencesKeys.SHOW_SCROLLBAR] ?: true diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 48dde7b34..adab461ca 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -8,6 +8,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.database.ContentObserver import android.graphics.Bitmap import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo @@ -15,7 +16,10 @@ import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.SystemClock +import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.graphics.drawable.toBitmap @@ -230,8 +234,27 @@ class MusicService : MediaLibraryService() { private var shouldResumeAfterHeadsetReconnect = false private var lastNoisyPauseRealtimeMs = 0L private var resumeOnHeadsetReconnectEnabled = false + private var pauseOnVolumeZeroEnabled = false private var temporaryForegroundStartedInOnCreate = false + // Observes the device's media stream volume and pauses playback when it + // reaches 0, if the user has enabled the "pause on volume zero" preference. + private val systemVolumeObserver by lazy { + object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + if (!pauseOnVolumeZeroEnabled) return + val streamVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (streamVolume == 0) { + val player = mediaSession?.player ?: engine.masterPlayer + if (player.isPlaying) { + player.pause() + Timber.tag(TAG).d("pauseOnVolumeZero: paused because system media volume reached 0") + } + } + } + } + } + companion object { private const val TAG = "MusicService_PixelPlay" const val NOTIFICATION_ID = 101 @@ -411,6 +434,7 @@ class MusicService : MediaLibraryService() { syncLocalListeningStatsFromPlayer(engine.masterPlayer) engine.masterPlayer.addListener(playerListener) + registerSystemVolumeObserver() // Handle player swaps (crossfade) to keep MediaSession in sync engine.setOnPlayerAboutToBeReleasedListener { oldPlayer -> @@ -492,6 +516,12 @@ class MusicService : MediaLibraryService() { } } + serviceScope.launch { + userPreferencesRepository.pauseOnVolumeZeroFlow.collect { enabled -> + pauseOnVolumeZeroEnabled = enabled + } + } + serviceScope.launch { userPreferencesRepository.persistentShuffleEnabledFlow.collect { enabled -> persistentShuffleEnabled = enabled @@ -1219,6 +1249,13 @@ class MusicService : MediaLibraryService() { private val playerListener = object : Player.Listener { override fun onVolumeChanged(volume: Float) { replayGainProcessor.onPlayerVolumeChanged(volume) + if (pauseOnVolumeZeroEnabled && volume == 0f) { + val player = mediaSession?.player ?: engine.masterPlayer + if (player.isPlaying) { + player.pause() + Timber.tag(TAG).d("pauseOnVolumeZero: paused playback because volume reached 0") + } + } } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -1464,6 +1501,7 @@ class MusicService : MediaLibraryService() { widgetUpdateManager.cancel() castSyncCoordinator.stop() unregisterHeadsetReconnectMonitor() + unregisterSystemVolumeObserver() wearStatePublisher.clearState() replayGainProcessor.cancel() @@ -1526,6 +1564,18 @@ class MusicService : MediaLibraryService() { clearHeadsetReconnectResume() } + private fun registerSystemVolumeObserver() { + contentResolver.registerContentObserver( + Settings.System.CONTENT_URI, + true, + systemVolumeObserver + ) + } + + private fun unregisterSystemVolumeObserver() { + runCatching { contentResolver.unregisterContentObserver(systemVolumeObserver) } + } + private fun maybeResumeAfterHeadsetReconnect() { if (!resumeOnHeadsetReconnectEnabled || !shouldResumeAfterHeadsetReconnect) return diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 04844b159..6f631b892 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -244,4 +244,6 @@ إزالة جميع أغلفة الألبومات (أغلفة متعددة مختلفة) خطأ في التشغيل: %1$s + إيقاف مؤقت عند وصول مستوى الصوت إلى الصفر + إيقاف التشغيل تلقائيًا مؤقتًا عندما يكون مستوى الصوت 0 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fbadb1f24..4fdf90ff6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -125,4 +125,6 @@ Album abspielen Album zufällig abspielen Cover für %1$s + Pausieren, wenn Lautstärke null erreicht + Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 70e2eb6ee..8c724bf3d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -125,4 +125,6 @@ Reproducir álbum Reproducción aleatoria del álbum Carátula del álbum para %1$s + Pausar cuando el volumen llegue a cero + Pausar automáticamente la reproducción cuando el volumen sea 0 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4d8252874..b9c1f3414 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -125,4 +125,6 @@ Lire l\'album Lire l\'album en aléatoire Pochette d\'album pour %1$s + Mettre en pause quand le volume atteint zéro + Mettre automatiquement en pause la lecture lorsque le volume est à 0 diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index cccb78d7f..6294a14bf 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -125,4 +125,6 @@ Putar album Putar acak album Sampul album untuk %1$s + Jeda saat volume mencapai nol + Otomatis menjeda pemutaran saat volume diatur ke 0 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 85feae577..642e4d607 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -125,4 +125,6 @@ Riproduci album Riproduzione casuale album Copertina album per %1$s + Metti in pausa quando il volume raggiunge zero + Metti automaticamente in pausa la riproduzione quando il volume è 0 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fa75a324c..53620278b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -125,4 +125,6 @@ 앨범 재생 앨범 셔플 재생 %1$s의 앨범 아트 + 볼륨이 0이 되면 일시정지 + 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 9855d0457..3b0960fac 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -125,4 +125,6 @@ Spill album Bland album Albumkunst for %1$s + Sett på pause når volumet er null + Sett automatisk avspillingen på pause når volumet settes til 0 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 717630c20..3caf81f18 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -125,4 +125,6 @@ Воспроизвести альбом Перемешать альбом Обложка альбома: %1$s + Пауза при нулевой громкости + Автоматически приостанавливать воспроизведение, когда громкость равна 0 \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2f0dfdccb..8270ddfef 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -125,4 +125,6 @@ Albümü oynat Albümü karışık oynat %1$s için albüm kapağı + Ses sıfıra ulaştığında duraklat + Ses seviyesi 0'a ayarlandığında oynatmayı otomatik olarak duraklat diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0818d79a1..cc3e0c034 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -125,4 +125,6 @@ 播放专辑 随机播放专辑 %1$s 的专辑封面 + 音量为零时暂停 + 当音量设置为 0 时自动暂停播放 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cb4e6d90..df8e12425 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,4 +125,6 @@ Play album Shuffle play album Album art for %1$s + Pause when volume reaches zero + Automatically pause playback when the volume is set to 0 From a19102e40b7dfef520b623efbc4e19b0a99ace23 Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 19 Jun 2026 13:46:40 +0200 Subject: [PATCH 2/4] feat(settings): add pause-on-volume-zero toggle to playback settings - Add Volume subsection with SwitchSettingItem to SettingsCategoryScreen - Add pauseOnVolumeZero field to SettingsUiState - Add pauseOnVolumeZeroFlow to SettingsViewModel combine() block - Add setPauseOnVolumeZero() setter in SettingsViewModel - Inject settings_volume_section, settings_pause_on_volume_zero and _desc into strings_settings.xml for all 12 locales - Remove duplicate keys from strings.xml (AAPT2 duplicate resources fix) - Fix rogue spaces in Korean strings_settings.xml entries --- app/src/debug/res/values-de/strings.xml | 1 + app/src/debug/res/values-fr/strings.xml | 1 + app/src/debug/res/values-ko/strings.xml | 1 + app/src/debug/res/values-nb/strings.xml | 1 + app/src/debug/res/values-ru/strings.xml | 1 + app/src/debug/res/values/strings.xml | 1 + .../screens/SettingsCategoryScreen.kt | 10 +++++ .../viewmodel/SettingsViewModel.kt | 45 ++++++++++++------- app/src/main/res/values-ar/strings.xml | 2 - .../main/res/values-ar/strings_settings.xml | 3 ++ app/src/main/res/values-de/strings.xml | 2 - .../main/res/values-de/strings_settings.xml | 3 ++ app/src/main/res/values-es/strings.xml | 2 - .../main/res/values-es/strings_settings.xml | 3 ++ app/src/main/res/values-fr/strings.xml | 2 - .../main/res/values-fr/strings_settings.xml | 3 ++ app/src/main/res/values-in/strings.xml | 2 - .../main/res/values-in/strings_settings.xml | 3 ++ app/src/main/res/values-it/strings.xml | 2 - .../main/res/values-it/strings_settings.xml | 3 ++ app/src/main/res/values-ko/strings.xml | 2 - .../main/res/values-ko/strings_settings.xml | 3 ++ app/src/main/res/values-nb/strings.xml | 2 - .../main/res/values-nb/strings_settings.xml | 3 ++ app/src/main/res/values-ru/strings.xml | 2 - .../main/res/values-ru/strings_settings.xml | 3 ++ app/src/main/res/values-tr/strings.xml | 4 +- .../main/res/values-tr/strings_settings.xml | 3 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 - .../res/values-zh-rCN/strings_settings.xml | 3 ++ app/src/main/res/values/strings.xml | 2 - app/src/main/res/values/strings_settings.xml | 3 ++ 32 files changed, 83 insertions(+), 40 deletions(-) diff --git a/app/src/debug/res/values-de/strings.xml b/app/src/debug/res/values-de/strings.xml index 9c5a1e3a8..29fbcffab 100644 --- a/app/src/debug/res/values-de/strings.xml +++ b/app/src/debug/res/values-de/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] Pausieren, wenn Lautstärke null erreicht Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird + Lautstärke diff --git a/app/src/debug/res/values-fr/strings.xml b/app/src/debug/res/values-fr/strings.xml index e915e2f10..e7290328f 100644 --- a/app/src/debug/res/values-fr/strings.xml +++ b/app/src/debug/res/values-fr/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] Mettre en pause quand le volume atteint zéro Mettre automatiquement en pause la lecture lorsque le volume est à 0 + Volume diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml index 5081f3546..c36700af8 100644 --- a/app/src/debug/res/values-ko/strings.xml +++ b/app/src/debug/res/values-ko/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] 볼륨이 0이 되면 일시정지 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 + 볼륨 diff --git a/app/src/debug/res/values-nb/strings.xml b/app/src/debug/res/values-nb/strings.xml index 586f91ab2..adc9a0cc9 100644 --- a/app/src/debug/res/values-nb/strings.xml +++ b/app/src/debug/res/values-nb/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] Sett på pause når volumet er null Sett automatisk avspillingen på pause når volumet settes til 0 + Volum diff --git a/app/src/debug/res/values-ru/strings.xml b/app/src/debug/res/values-ru/strings.xml index 8f0a2f8cc..a30dfa484 100644 --- a/app/src/debug/res/values-ru/strings.xml +++ b/app/src/debug/res/values-ru/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] Пауза при нулевой громкости Автоматически приостанавливать воспроизведение, когда громкость равна 0 + Громкость diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 70e489601..478cab606 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -3,4 +3,5 @@ PixelPlayer [D] Pause when volume reaches zero Automatically pause playback when the volume is set to 0 + Volume diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index d1ff2628b..75276f9d8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -806,6 +806,16 @@ fun SettingsCategoryScreen( ) } + SettingsSubsection(title = stringResource(R.string.settings_volume_section)) { + SwitchSettingItem( + title = stringResource(R.string.settings_pause_on_volume_zero), + subtitle = stringResource(R.string.settings_pause_on_volume_zero_desc), + checked = uiState.pauseOnVolumeZero, + onCheckedChange = { settingsViewModel.setPauseOnVolumeZero(it) }, + leadingIcon = { Icon(painterResource(R.drawable.rounded_volume_down_24), null, tint = MaterialTheme.colorScheme.secondary) } + ) + } + SettingsSubsection(title = stringResource(R.string.settings_headphones_section)) { SwitchSettingItem( title = stringResource(R.string.settings_headphones_resume_title), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index d07d8b3df..d5ca7e577 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -68,6 +68,7 @@ data class SettingsUiState( val launchTab: String = LaunchTab.HOME, val keepPlayingInBackground: Boolean = true, val disableCastAutoplay: Boolean = false, + val pauseOnVolumeZero: Boolean = false, val resumeOnHeadsetReconnect: Boolean = false, val showQueueHistory: Boolean = true, val isCrossfadeEnabled: Boolean = false, @@ -154,6 +155,7 @@ private sealed interface SettingsUiUpdate { data class Group2( val keepPlayingInBackground: Boolean, val disableCastAutoplay: Boolean, + val pauseOnVolumeZero: Boolean, val resumeOnHeadsetReconnect: Boolean, val showQueueHistory: Boolean, val isCrossfadeEnabled: Boolean, @@ -547,6 +549,7 @@ class SettingsViewModel @Inject constructor( combine( userPreferencesRepository.keepPlayingInBackgroundFlow, userPreferencesRepository.disableCastAutoplayFlow, + userPreferencesRepository.pauseOnVolumeZeroFlow, userPreferencesRepository.resumeOnHeadsetReconnectFlow, userPreferencesRepository.showQueueHistoryFlow, userPreferencesRepository.isCrossfadeEnabledFlow, @@ -568,29 +571,31 @@ class SettingsViewModel @Inject constructor( SettingsUiUpdate.Group2( keepPlayingInBackground = values[0] as Boolean, disableCastAutoplay = values[1] as Boolean, - resumeOnHeadsetReconnect = values[2] as Boolean, - showQueueHistory = values[3] as Boolean, - isCrossfadeEnabled = values[4] as Boolean, - hiFiModeEnabled = values[5] as Boolean, - crossfadeDuration = values[6] as Int, - persistentShuffleEnabled = values[7] as Boolean, - folderBackGestureNavigation = values[8] as Boolean, - lyricsSourcePreference = values[9] as LyricsSourcePreference, - autoScanLrcFiles = values[10] as Boolean, - blockedDirectories = @Suppress("UNCHECKED_CAST") (values[11] as Set), - hapticsEnabled = values[12] as Boolean, - immersiveLyricsEnabled = values[13] as Boolean, - immersiveLyricsTimeout = values[14] as Long, - animatedLyricsBlurEnabled = values[15] as Boolean, - animatedLyricsBlurStrength = values[16] as Float, - disableBlurAllOver = values[17] as Boolean, - showScrollbar = values[18] as Boolean + pauseOnVolumeZero = values[2] as Boolean, + resumeOnHeadsetReconnect = values[3] as Boolean, + showQueueHistory = values[4] as Boolean, + isCrossfadeEnabled = values[5] as Boolean, + hiFiModeEnabled = values[6] as Boolean, + crossfadeDuration = values[7] as Int, + persistentShuffleEnabled = values[8] as Boolean, + folderBackGestureNavigation = values[9] as Boolean, + lyricsSourcePreference = values[10] as LyricsSourcePreference, + autoScanLrcFiles = values[11] as Boolean, + blockedDirectories = @Suppress("UNCHECKED_CAST") (values[12] as Set), + hapticsEnabled = values[13] as Boolean, + immersiveLyricsEnabled = values[14] as Boolean, + immersiveLyricsTimeout = values[15] as Long, + animatedLyricsBlurEnabled = values[16] as Boolean, + animatedLyricsBlurStrength = values[17] as Float, + disableBlurAllOver = values[18] as Boolean, + showScrollbar = values[19] as Boolean ) }.collect { update -> _uiState.update { state -> state.copy( keepPlayingInBackground = update.keepPlayingInBackground, disableCastAutoplay = update.disableCastAutoplay, + pauseOnVolumeZero = update.pauseOnVolumeZero, resumeOnHeadsetReconnect = update.resumeOnHeadsetReconnect, showQueueHistory = update.showQueueHistory, isCrossfadeEnabled = update.isCrossfadeEnabled, @@ -866,6 +871,12 @@ class SettingsViewModel @Inject constructor( } } + fun setPauseOnVolumeZero(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setPauseOnVolumeZero(enabled) + } + } + fun setResumeOnHeadsetReconnect(enabled: Boolean) { viewModelScope.launch { userPreferencesRepository.setResumeOnHeadsetReconnect(enabled) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6f631b892..04844b159 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -244,6 +244,4 @@ إزالة جميع أغلفة الألبومات (أغلفة متعددة مختلفة) خطأ في التشغيل: %1$s - إيقاف مؤقت عند وصول مستوى الصوت إلى الصفر - إيقاف التشغيل تلقائيًا مؤقتًا عندما يكون مستوى الصوت 0 diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index 78d187b68..c491f2253 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -297,4 +297,7 @@ وحدات عدد %1$d · إصدار %2$s · إصدار المخطط البرمجي %3$d Korean (الكورية) Norwegian (النرويجية بوكمول) + مستوى الصوت + إيقاف مؤقت عند وصول مستوى الصوت إلى الصفر + إيقاف التشغيل تلقائيًا مؤقتًا عندما يكون مستوى الصوت 0 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4fdf90ff6..fbadb1f24 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -125,6 +125,4 @@ Album abspielen Album zufällig abspielen Cover für %1$s - Pausieren, wenn Lautstärke null erreicht - Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index 69f37192e..f6d91cadc 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -637,4 +637,7 @@ Telegram öffnen Avatar von %1$s Icon von %1$s + Lautstärke + Pausieren, wenn Lautstärke null erreicht + Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8c724bf3d..70e2eb6ee 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -125,6 +125,4 @@ Reproducir álbum Reproducción aleatoria del álbum Carátula del álbum para %1$s - Pausar cuando el volumen llegue a cero - Pausar automáticamente la reproducción cuando el volumen sea 0 diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index b66c4aa60..157d11de5 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -637,4 +637,7 @@ Abrir Telegram Avatar de %1$s Icono de %1$s + Volumen + Pausar cuando el volumen llegue a cero + Pausar automáticamente la reproducción cuando el volumen sea 0 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b9c1f3414..4d8252874 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -125,6 +125,4 @@ Lire l\'album Lire l\'album en aléatoire Pochette d\'album pour %1$s - Mettre en pause quand le volume atteint zéro - Mettre automatiquement en pause la lecture lorsque le volume est à 0 diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 13f14c868..8928824ab 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -633,4 +633,7 @@ Ouvrir Telegram Avatar de %1$s Icône de %1$s + Volume + Mettre en pause quand le volume atteint zéro + Mettre automatiquement en pause la lecture lorsque le volume est à 0 diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6294a14bf..cccb78d7f 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -125,6 +125,4 @@ Putar album Putar acak album Sampul album untuk %1$s - Jeda saat volume mencapai nol - Otomatis menjeda pemutaran saat volume diatur ke 0 diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index 6aab6877b..5af8c0674 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -633,4 +633,7 @@ Buka Telegram Avatar %1$s Ikon %1$s + Volume + Jeda saat volume mencapai nol + Otomatis menjeda pemutaran saat volume diatur ke 0 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 642e4d607..85feae577 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -125,6 +125,4 @@ Riproduci album Riproduzione casuale album Copertina album per %1$s - Metti in pausa quando il volume raggiunge zero - Metti automaticamente in pausa la riproduzione quando il volume è 0 diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index 234a0e911..ad4eacdcd 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -637,4 +637,7 @@ Apri Telegram Avatar di %1$s Icona di %1$s + Volume + Metti in pausa quando il volume raggiunge zero + Metti automaticamente in pausa la riproduzione quando il volume è 0 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 53620278b..fa75a324c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -125,6 +125,4 @@ 앨범 재생 앨범 셔플 재생 %1$s의 앨범 아트 - 볼륨이 0이 되면 일시정지 - 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index bcc63f057..6ad57c55f 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -637,4 +637,7 @@ Telegram 열기 %1$s 아바타 %1$s 아이콘 + 볼륨 + 볼륨이 0이 되면 일시정지 + 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 3b0960fac..9855d0457 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -125,6 +125,4 @@ Spill album Bland album Albumkunst for %1$s - Sett på pause når volumet er null - Sett automatisk avspillingen på pause når volumet settes til 0 diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index fd101cb66..f937ae7a8 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -637,4 +637,7 @@ Åpne Telegram Avatar av %1$s Ikon av %1$s + Volum + Sett på pause når volumet er null + Sett automatisk avspillingen på pause når volumet settes til 0 \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3caf81f18..717630c20 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -125,6 +125,4 @@ Воспроизвести альбом Перемешать альбом Обложка альбома: %1$s - Пауза при нулевой громкости - Автоматически приостанавливать воспроизведение, когда громкость равна 0 \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index 997331e07..f9346757f 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -637,4 +637,7 @@ Открыть Telegram Аватар %1$s Значок %1$s + Громкость + Пауза при нулевой громкости + Автоматически приостанавливать воспроизведение, когда громкость равна 0 diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8270ddfef..4c66cf1e1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -126,5 +126,7 @@ Albümü karışık oynat %1$s için albüm kapağı Ses sıfıra ulaştığında duraklat - Ses seviyesi 0'a ayarlandığında oynatmayı otomatik olarak duraklat + + Ses seviyesi 0\'a ayarlandığında oynatmayı otomatik olarak duraklat + Ses diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 1f4dc593c..3f57b851a 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -637,4 +637,7 @@ Telegram\'ı aç %1$s avatarı %1$s simgesi + Ses + Ses sıfıra ulaştığında duraklat + Ses seviyesi 0\'a ayarlandığında oynatmayı otomatik olarak duraklat diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cc3e0c034..0818d79a1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -125,6 +125,4 @@ 播放专辑 随机播放专辑 %1$s 的专辑封面 - 音量为零时暂停 - 当音量设置为 0 时自动暂停播放 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index 0822b25e0..61fa368ed 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -637,4 +637,7 @@ 打开 Telegram %1$s 的头像 %1$s 的图标 + 音量 + 音量为零时暂停 + 当音量设置为 0 时自动暂停播放 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df8e12425..2cb4e6d90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,6 +125,4 @@ Play album Shuffle play album Album art for %1$s - Pause when volume reaches zero - Automatically pause playback when the volume is set to 0 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 298046dec..5806c7614 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -637,4 +637,7 @@ Open Telegram Avatar of %1$s Icon of %1$s + Volume + Pause when volume reaches zero + Automatically pause playback when the volume is set to 0 From 59a7538b951d5b728fc682bfbc7d879ccc33151e Mon Sep 17 00:00:00 2001 From: Amonoman Date: Fri, 19 Jun 2026 20:03:39 +0200 Subject: [PATCH 3/4] fix(strings-tr): remove duplicate keys from strings.xml --- app/src/main/res/values-tr/strings.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4c66cf1e1..5b1dd61ba 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -125,8 +125,5 @@ Albümü oynat Albümü karışık oynat %1$s için albüm kapağı - Ses sıfıra ulaştığında duraklat - Ses seviyesi 0\'a ayarlandığında oynatmayı otomatik olarak duraklat - Ses From 761bac01ff738727574c529d4a3c68b1e1fb85ec Mon Sep 17 00:00:00 2001 From: Amonoman Date: Sun, 21 Jun 2026 10:22:03 +0200 Subject: [PATCH 4/4] perf(sync): chunk Telegram sync to prevent OOM on large channels Processing all songs in a single pass exhausted the 256 MB heap on devices with large Telegram channels (reproduced at 65k songs, #2172). Songs are now processed and flushed to Room in batches of 500, keeping peak memory bounded regardless of channel size. - Album songCounts accumulated across all chunks; every chunk re-upserts albums it touches so the final count is always correct - Deletion diff runs after all chunks using the complete synced ID set - Deletions batched in 500s to respect SQLite variable limits - Remove duplicate getAllArtistsListRaw() call (was called twice) Co-authored-by: Claude --- .../pixelplay/data/worker/SyncWorker.kt | 375 ++++++++++-------- 1 file changed, 199 insertions(+), 176 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 02d160a44..65f02e098 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 @@ -1218,6 +1218,10 @@ constructor( const val PROGRESS_TOTAL = "progress_total" const val PROGRESS_PHASE = "progress_phase" const val OUTPUT_TOTAL_SONGS = "output_total_songs" + // 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 + private const val NETEASE_SONG_ID_OFFSET = 3_000_000_000_000L private const val NETEASE_ALBUM_ID_OFFSET = 4_000_000_000_000L private const val NETEASE_ARTIST_ID_OFFSET = 5_000_000_000_000L @@ -1313,216 +1317,235 @@ constructor( } } - // Logic to sync Telegram songs into main DB with Unified Library Support + // 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 + // TELEGRAM_SYNC_CHUNK_SIZE so we never hold the full 65k-song list in memory + // alongside the four derived collections (songs/albums/artists/crossRefs). + // Each chunk is inserted immediately and then GC-eligible before the next + // chunk is allocated. The full song ID set is collected across all chunks + // so deletion of removed songs still works correctly at the end. private suspend fun syncTelegramData() { Log.i(TAG, "Syncing Telegram songs to main database (Unified Mode)...") try { val telegramSongs = telegramDao.getAllTelegramSongs().first() val channels = telegramDao.getAllChannels().first().associateBy { it.chatId } val existingUnifiedTelegramIds = musicDao.getAllTelegramSongIds() - - if (telegramSongs.isEmpty()) { + + if (telegramSongs.isEmpty()) { if (existingUnifiedTelegramIds.isNotEmpty()) { musicDao.clearAllTelegramSongs() } Log.d(TAG, "No Telegram songs to sync.") - return + return } - // 1. Pre-load Local Data for Merging - val existingArtists = musicDao.getAllArtistsListRaw().associate { it.name.trim().lowercase() to it.id } - val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0).associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id } - val existingArtistImageUrls = musicDao.getAllArtistsListRaw().associate { it.id to it.imageUrl } - val nextArtistId = AtomicLong((musicDao.getMaxArtistId() ?: 0L) + 1) + // 1. Pre-load local data for merging — loaded once, shared across all chunks. + // getAllArtistsListRaw() called once only (was called twice before). + val allExistingArtists = musicDao.getAllArtistsListRaw() + val existingArtists = allExistingArtists.associate { it.name.trim().lowercase() to it.id } + 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() - val songsToInsert = mutableListOf() - val artistsToInsert = mutableMapOf() // Map to dedup by ID - val albumsToInsert = mutableMapOf() // Map to dedup by ID - val crossRefsToInsert = mutableListOf() - - telegramSongs.forEach { tSong -> - val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream" - // Synthetic negative ID for Song to check existence, but we want to merge metadata - // We use negative IDs for songs to definitively identify them as Telegram-sourced in the DB - // This prevents collision with MediaStore numeric IDs. - val songId = -(tSong.id.hashCode().toLong().absoluteValue) - val finalSongId = if (songId == 0L) -1L else songId - - // 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 + // Collect every synced song ID across chunks so we can diff deletions at the end. + val syncedTelegramSongIds = HashSet(telegramSongs.size) + // Track album song counts across all chunks so cross-chunk albums get the right total. + val albumSongCounts = mutableMapOf() + var totalSynced = 0 + + telegramSongs.chunked(TELEGRAM_SYNC_CHUNK_SIZE).forEach { chunk -> + // Per-chunk collections — allocated, used, then released each iteration. + val songsToInsert = ArrayList(chunk.size) + val artistsToInsert = mutableMapOf() + val albumsToInsert = mutableMapOf() + val crossRefsToInsert = mutableListOf() + + chunk.forEach { tSong -> + val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream" + 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 } - 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 } - resolvedAlbumArtUri = tSong.resolveAlbumArtUri() - } catch (e: Exception) { - // Ignore read errors, fall back to TdApi metadata } - } - - // 3. Multi-Artist Processing - val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName - val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims) - - // Process Primary Artist (First in list) - val primaryArtistName = splitArtists.firstOrNull()?.trim() ?: "Unknown Artist" - - var primaryArtistId = -1L - - splitArtists.forEachIndexed { index, individualArtistName -> - val cleanName = individualArtistName.trim() - val lowerName = cleanName.lowercase() - - // Check if artist exists locally (Merge logic) - val existingId = existingArtists[lowerName] - - val finalArtistId = if (existingId != null) { - existingId // Use Positive MediaStore ID - } else { - // Generate consistent negative ID for Telegram-only artist - val synthId = -(cleanName.hashCode().toLong().absoluteValue) - if (synthId == 0L) -1L else synthId - } - - if (index == 0) primaryArtistId = finalArtistId - // Add to Artist Insert Map - if (!artistsToInsert.containsKey(finalArtistId)) { - artistsToInsert[finalArtistId] = ArtistEntity( - id = finalArtistId, - name = cleanName, - trackCount = 0, // Will be recalculated by Room or logic - imageUrl = existingArtistImageUrls[finalArtistId] // Keep existing image if merging - ) + // 3. Multi-Artist Processing + val rawArtistName = if (realArtistName.isBlank()) "Unknown Artist" else realArtistName + val splitArtists = rawArtistName.splitArtistsByDelimiters(delimiters, wordDelims) + var primaryArtistId = -1L + + splitArtists.forEachIndexed { index, individualArtistName -> + val cleanName = individualArtistName.trim() + val lowerName = cleanName.lowercase() + val existingId = existingArtists[lowerName] + val finalArtistId = if (existingId != null) { + existingId + } else { + val synthId = -(cleanName.hashCode().toLong().absoluteValue) + if (synthId == 0L) -1L else synthId + } + if (index == 0) primaryArtistId = finalArtistId + if (!artistsToInsert.containsKey(finalArtistId)) { + artistsToInsert[finalArtistId] = ArtistEntity( + id = finalArtistId, + name = cleanName, + trackCount = 0, + imageUrl = existingArtistImageUrls[finalArtistId] + ) + } + crossRefsToInsert.add(SongArtistCrossRef( + songId = finalSongId, + artistId = finalArtistId, + isPrimary = (index == 0) + )) } - // Add Cross Ref - crossRefsToInsert.add(SongArtistCrossRef( - songId = finalSongId, - artistId = finalArtistId, - isPrimary = (index == 0) - )) - } - - // 4. Album Logic - // Try to match existing album by Name + Album Artist - val albumKey = "${realAlbumName.trim().lowercase()}_${realAlbumArtist.trim().lowercase()}" - val existingAlbumId = existingAlbums[albumKey] - - val finalAlbumId = if (existingAlbumId != null) { - existingAlbumId // Merge with local album - } else { - // Synthetic negative ID - val synthId = -(realAlbumName.hashCode().toLong().absoluteValue) - if (synthId == 0L) -1L else synthId - } - - if (!albumsToInsert.containsKey(finalAlbumId)) { - albumsToInsert[finalAlbumId] = AlbumEntity( + // 4. Album Logic + val albumKey = "${realAlbumName.trim().lowercase()}_${realAlbumArtist.trim().lowercase()}" + val existingAlbumId = existingAlbums[albumKey] + val finalAlbumId = if (existingAlbumId != null) { + existingAlbumId + } else { + val synthId = -(realAlbumName.hashCode().toLong().absoluteValue) + if (synthId == 0L) -1L else synthId + } + // Always put the album in albumsToInsert (not just first occurrence) so that + // when this chunk is flushed the updated songCount upsert reaches the DB, + // even if this album was first seen in a previous chunk. + albumsToInsert[finalAlbumId] = AlbumEntity( id = finalAlbumId, title = realAlbumName, - artistName = realAlbumArtist, - artistId = primaryArtistId, // Link to primary song artist (or album artist if we resolved it properly) - songCount = 0, + artistName = realAlbumArtist, + artistId = primaryArtistId, + songCount = 0, // overwritten with correct count before upsert below dateAdded = realDateAdded, year = realYear, albumArtUriString = resolvedAlbumArtUri ) + + // 5. Build Final Song Entity + val telegramArtistRefs = splitArtists.mapIndexed { idx, name -> + val cleanName = name.trim() + val lowerName = cleanName.lowercase() + val artId = existingArtists[lowerName] + ?: artistsToInsert.values.find { it.name.equals(cleanName, ignoreCase = true) }?.id + ?: 0L + ArtistRef(id = artId, name = cleanName, isPrimary = idx == 0) + }.filter { it.name.isNotEmpty() } + + songsToInsert.add(SongEntity( + id = finalSongId, + title = realTitle, + artistName = rawArtistName, + artistId = primaryArtistId, + albumName = realAlbumName, + albumId = finalAlbumId, + albumArtist = realAlbumArtist, + duration = realDuration, + contentUriString = "telegram://${tSong.chatId}/${tSong.messageId}", + albumArtUriString = resolvedAlbumArtUri, + filePath = tSong.filePath, + parentDirectoryPath = File(tSong.filePath).parent ?: "/Telegram/$channelName", + dateAdded = tSong.dateAdded, + genre = realGenre, + trackNumber = realTrackNumber, + discNumber = realDiscNumber, + year = realYear, + isFavorite = false, + lyrics = realLyrics, + mimeType = tSong.mimeType, + bitrate = realBitrate, + sampleRate = realSampleRate, + telegramChatId = tSong.chatId, + telegramFileId = tSong.fileId, + artistsJson = serializeArtistRefs(telegramArtistRefs), + sourceType = SourceType.TELEGRAM + )) } - // 5. Build Final Song Entity - // Build artists JSON from the split artists and their resolved IDs - val telegramArtistRefs = splitArtists.mapIndexed { idx, name -> - val cleanName = name.trim() - val lowerName = cleanName.lowercase() - val artId = existingArtists[lowerName] - ?: artistsToInsert.values.find { it.name.equals(cleanName, ignoreCase = true) }?.id - ?: 0L - ArtistRef(id = artId, name = cleanName, isPrimary = idx == 0) - }.filter { it.name.isNotEmpty() } - - val songEntity = SongEntity( - id = finalSongId, - title = realTitle, - artistName = rawArtistName, // Store full string for display - artistId = primaryArtistId, - albumName = realAlbumName, - albumId = finalAlbumId, - albumArtist = realAlbumArtist, - duration = realDuration, - contentUriString = "telegram://${tSong.chatId}/${tSong.messageId}", - albumArtUriString = resolvedAlbumArtUri, - filePath = tSong.filePath, - parentDirectoryPath = File(tSong.filePath).parent ?: "/Telegram/$channelName", - dateAdded = tSong.dateAdded, - genre = realGenre, - trackNumber = realTrackNumber, - discNumber = realDiscNumber, - year = realYear, - isFavorite = false, - lyrics = realLyrics, - mimeType = tSong.mimeType, - bitrate = realBitrate, - sampleRate = realSampleRate, - telegramChatId = tSong.chatId, - telegramFileId = tSong.fileId, - artistsJson = serializeArtistRefs(telegramArtistRefs), - sourceType = SourceType.TELEGRAM + // Accumulate album song counts across chunks — albums can span chunk boundaries. + songsToInsert.forEach { song -> + albumSongCounts[song.albumId] = (albumSongCounts[song.albumId] ?: 0) + 1 + } + + // Flush this chunk to DB. albumSongCounts already reflects all songs seen so far + // 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. + val finalAlbums = albumsToInsert.values.map { album -> + album.copy(songCount = albumSongCounts[album.id] ?: 0) + } + musicDao.incrementalSyncMusicData( + songs = songsToInsert, + albums = finalAlbums, + artists = artistsToInsert.values.toList(), + crossRefs = crossRefsToInsert, + deletedSongIds = emptyList() // Deletions handled after all chunks ) - songsToInsert.add(songEntity) + totalSynced += songsToInsert.size + Log.d(TAG, "Telegram sync: flushed chunk of ${songsToInsert.size} songs ($totalSynced / ${telegramSongs.size} total)") + // chunk-local collections go out of scope here and are GC-eligible } - - // Calculate song counts for the albums we are inserting - val albumCounts = songsToInsert.groupingBy { it.albumId }.eachCount() - val finalAlbums = albumsToInsert.values.map { album -> - album.copy(songCount = albumCounts[album.id] ?: 0) - } - val syncedTelegramSongIds = songsToInsert.map { it.id }.toHashSet() + // Delete songs that are no longer present in the Telegram DB. val deletedUnifiedSongIds = existingUnifiedTelegramIds.filterNot { it in syncedTelegramSongIds } + if (deletedUnifiedSongIds.isNotEmpty()) { + deletedUnifiedSongIds.chunked(500).forEach { batch -> + musicDao.incrementalSyncMusicData( + songs = emptyList(), + albums = emptyList(), + artists = emptyList(), + crossRefs = emptyList(), + deletedSongIds = batch + ) + } + Log.i(TAG, "Telegram sync: removed ${deletedUnifiedSongIds.size} deleted songs.") + } - // Upsert into MusicDao - musicDao.incrementalSyncMusicData( - songs = songsToInsert, - albums = finalAlbums, - artists = artistsToInsert.values.toList(), - crossRefs = crossRefsToInsert, - deletedSongIds = deletedUnifiedSongIds - ) - Log.i(TAG, "Synced ${songsToInsert.size} Telegram songs with Unified Metadata.") + Log.i(TAG, "Synced $totalSynced Telegram songs with Unified Metadata.") } catch (e: Exception) { Log.e(TAG, "Failed to sync Telegram data", e) }