diff --git a/app/src/debug/res/values-de/strings.xml b/app/src/debug/res/values-de/strings.xml
index 196c1e20f..29fbcffab 100644
--- a/app/src/debug/res/values-de/strings.xml
+++ b/app/src/debug/res/values-de/strings.xml
@@ -1,4 +1,7 @@
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 196c1e20f..e7290328f 100644
--- a/app/src/debug/res/values-fr/strings.xml
+++ b/app/src/debug/res/values-fr/strings.xml
@@ -1,4 +1,7 @@
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 196c1e20f..c36700af8 100644
--- a/app/src/debug/res/values-ko/strings.xml
+++ b/app/src/debug/res/values-ko/strings.xml
@@ -1,4 +1,7 @@
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..adc9a0cc9 100644
--- a/app/src/debug/res/values-nb/strings.xml
+++ b/app/src/debug/res/values-nb/strings.xml
@@ -1,4 +1,7 @@
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 196c1e20f..a30dfa484 100644
--- a/app/src/debug/res/values-ru/strings.xml
+++ b/app/src/debug/res/values-ru/strings.xml
@@ -1,4 +1,7 @@
PixelPlayer [D]
+ Пауза при нулевой громкости
+ Автоматически приостанавливать воспроизведение, когда громкость равна 0
+ Громкость
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
index 196c1e20f..478cab606 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,4 +1,7 @@
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/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/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)
}
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_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_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_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_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_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_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_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_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_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 2f0dfdccb..5b1dd61ba 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -125,4 +125,5 @@
Albümü oynat
Albümü karışık oynat
%1$s için albüm kapağı
+
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_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_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