diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/repository/PlaylistSongCountTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/repository/PlaylistSongCountTest.kt new file mode 100644 index 0000000..d13cd60 --- /dev/null +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/repository/PlaylistSongCountTest.kt @@ -0,0 +1,108 @@ +package com.lostf1sh.pixelplayeross.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.lostf1sh.pixelplayeross.data.database.LocalPlaylistDao +import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase +import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaylistSongCountTest { + + private lateinit var db: PixelPlayerDatabase + private lateinit var dao: LocalPlaylistDao + private lateinit var dataStore: DataStore + private lateinit var repo: PlaylistPreferencesRepository + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, PixelPlayerDatabase::class.java) + .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) + .allowMainThreadQueries() + .build() + dao = db.localPlaylistDao() + dataStore = PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile("test_settings_${System.nanoTime()}") + } + val userPrefs = UserPreferencesRepository(dataStore, Json { ignoreUnknownKeys = true }) + repo = PlaylistPreferencesRepository(dao, userPrefs) + } + + @After + fun teardown() { + db.close() + } + + private suspend fun countFor(playlistId: String): Int = + repo.userPlaylistsFlow.first().first { it.id == playlistId }.songIds.size + + @Test + fun menuSongCount_reflectsAddAndRemove() = runTest { + val playlist = repo.createPlaylist(name = "J-Pop", songIds = listOf("10", "20", "30")) + assertEquals("initial count", 3, countFor(playlist.id)) + + repo.removeSongFromPlaylist(playlist.id, "20") + assertEquals("after removing one song", 2, countFor(playlist.id)) + + repo.removeSongFromPlaylist(playlist.id, "30") + assertEquals("after removing a second song", 1, countFor(playlist.id)) + + repo.addSongsToPlaylist(playlist.id, listOf("40")) + assertEquals("after adding one song", 2, countFor(playlist.id)) + } + + @Test + fun concurrentRemovals_doNotLoseUpdates() = runBlocking { + val playlist = repo.createPlaylist( + name = "Race", + songIds = listOf("1", "2", "3", "4", "5") + ) + assertEquals(5, countFor(playlist.id)) + + coroutineScope { + listOf("1", "2", "3", "4").forEach { id -> + launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, id) } + } + } + + assertEquals("All concurrent removals must persist", 1, countFor(playlist.id)) + } + + @Test + fun quickRemoveThenAdd_keepsCountAccurate() = runBlocking { + val playlist = repo.createPlaylist( + name = "J-Pop", + songIds = listOf("1", "2", "3", "4", "5", "6") + ) + assertEquals(6, countFor(playlist.id)) + + coroutineScope { + launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, "2") } + launch(Dispatchers.IO) { repo.removeSongFromPlaylist(playlist.id, "4") } + } + assertEquals("count after removing two songs", 4, countFor(playlist.id)) + + repo.addSongsToPlaylist(playlist.id, listOf("7", "8")) + assertEquals("count after adding two songs", 6, countFor(playlist.id)) + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt index b2c52a3..679d814 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt @@ -1577,6 +1577,7 @@ interface MusicDao { artist_id = :artistId, artists_json = :artistsJson, album_name = :album, + album_artist = :albumArtist, genre = :genre, track_number = :trackNumber, disc_number = :discNumber, @@ -1593,6 +1594,7 @@ interface MusicDao { artistId: Long, artistsJson: String?, album: String, + albumArtist: String?, genre: String?, trackNumber: Int, discNumber: Int? @@ -1606,6 +1608,7 @@ interface MusicDao { artistId: Long, artistsJson: String?, album: String, + albumArtist: String?, genre: String?, trackNumber: Int, discNumber: Int?, @@ -1623,6 +1626,7 @@ interface MusicDao { artistId = artistId, artistsJson = artistsJson, album = album, + albumArtist = albumArtist, genre = genre, trackNumber = trackNumber, discNumber = discNumber diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt index ce4ff5e..9ab825b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt @@ -148,6 +148,7 @@ class SongMetadataEditor( title: String, artist: String, album: String, + albumArtist: String?, genre: String?, trackNumber: Int, discNumber: Int? @@ -218,6 +219,7 @@ class SongMetadataEditor( artistId = primaryArtistId, artistsJson = artistsJson, album = album, + albumArtist = albumArtist, genre = genre, trackNumber = trackNumber, discNumber = discNumber, @@ -444,6 +446,7 @@ class SongMetadataEditor( title = newTitle, artist = newArtist, album = newAlbum, + albumArtist = newAlbumArtist, genre = normalizedGenre, trackNumber = newTrackNumber, discNumber = newDiscNumber diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt index aba3314..542d37c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt @@ -22,6 +22,9 @@ class PlaylistPreferencesRepository @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository ) { private val migrationMutex = Mutex() + // Serializes playlist read-modify-write edits. Concurrent quick taps can otherwise + // read the same snapshot and let the last writer silently drop earlier edits. + private val editMutex = Mutex() @Volatile private var migrationChecked = false @@ -85,16 +88,24 @@ class PlaylistPreferencesRepository @Inject constructor( } suspend fun renamePlaylist(playlistId: String, newName: String) { - ensureMigratedIfNeeded() - val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return - val updated = existing.copy( - name = newName, - lastModified = System.currentTimeMillis() - ) - localPlaylistDao.upsertPlaylist(updated.toEntity()) + editMutex.withLock { + ensureMigratedIfNeeded() + val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return + val updated = existing.copy( + name = newName, + lastModified = System.currentTimeMillis() + ) + localPlaylistDao.upsertPlaylist(updated.toEntity()) + } } suspend fun updatePlaylist(playlist: Playlist) { + editMutex.withLock { + updatePlaylistLocked(playlist) + } + } + + private suspend fun updatePlaylistLocked(playlist: Playlist) { ensureMigratedIfNeeded() val updated = playlist.copy(lastModified = System.currentTimeMillis()) localPlaylistDao.upsertPlaylist(updated.toEntity()) @@ -102,11 +113,13 @@ class PlaylistPreferencesRepository @Inject constructor( } suspend fun addSongsToPlaylist(playlistId: String, songIdsToAdd: List) { - ensureMigratedIfNeeded() - val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return - if (existing.isSmartPlaylist) return - val merged = (existing.songIds + songIdsToAdd).distinct() - updatePlaylist(existing.copy(songIds = merged)) + editMutex.withLock { + ensureMigratedIfNeeded() + val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return + if (existing.isSmartPlaylist) return + val merged = (existing.songIds + songIdsToAdd).distinct() + updatePlaylistLocked(existing.copy(songIds = merged)) + } } suspend fun addOrRemoveSongFromPlaylists(songId: String, playlistIds: List): MutableList { @@ -132,17 +145,21 @@ class PlaylistPreferencesRepository @Inject constructor( } suspend fun removeSongFromPlaylist(playlistId: String, songIdToRemove: String) { - ensureMigratedIfNeeded() - val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return - if (existing.isSmartPlaylist) return - updatePlaylist(existing.copy(songIds = existing.songIds.filterNot { it == songIdToRemove })) + editMutex.withLock { + ensureMigratedIfNeeded() + val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return + if (existing.isSmartPlaylist) return + updatePlaylistLocked(existing.copy(songIds = existing.songIds.filterNot { it == songIdToRemove })) + } } suspend fun reorderSongsInPlaylist(playlistId: String, newSongOrderIds: List) { - ensureMigratedIfNeeded() - val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return - if (existing.isSmartPlaylist) return - updatePlaylist(existing.copy(songIds = newSongOrderIds)) + editMutex.withLock { + ensureMigratedIfNeeded() + val existing = userPlaylistsFlow.first().find { it.id == playlistId } ?: return + if (existing.isSmartPlaylist) return + updatePlaylistLocked(existing.copy(songIds = newSongOrderIds)) + } } suspend fun setPlaylistSongOrderMode(playlistId: String, modeValue: String) = @@ -171,15 +188,17 @@ class PlaylistPreferencesRepository @Inject constructor( } suspend fun removeSongFromAllPlaylists(songId: String) { - ensureMigratedIfNeeded() - val playlists = userPlaylistsFlow.first() - playlists.forEach { playlist -> - if (songId in playlist.songIds) { - updatePlaylist( - playlist.copy( - songIds = playlist.songIds.filterNot { it == songId } + editMutex.withLock { + ensureMigratedIfNeeded() + val playlists = userPlaylistsFlow.first() + playlists.forEach { playlist -> + if (songId in playlist.songIds) { + updatePlaylistLocked( + playlist.copy( + songIds = playlist.songIds.filterNot { it == songId } + ) ) - ) + } } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index 392956e..5593661 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -240,6 +240,7 @@ 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 appRebrandDialogShownFlow: Flow = @@ -406,7 +407,7 @@ constructor( val artistDelimitersFlow: Flow> = dataStore.data.map { preferences -> val stored = preferences[PreferencesKeys.ARTIST_DELIMITERS] - if (stored != null) { + val delimiters = if (stored != null) { try { json.decodeFromString>(stored) } catch (e: Exception) { @@ -415,6 +416,7 @@ constructor( } else { DEFAULT_ARTIST_DELIMITERS } + normalizeLegacyDefaultArtistDelimiters(delimiters) } suspend fun setArtistDelimiters(delimiters: List) { @@ -805,6 +807,11 @@ constructor( preferences[PreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] ?: false } + val pauseOnVolumeZeroFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] ?: false + } + suspend fun setReplayGainEnabled(enabled: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.REPLAYGAIN_ENABLED] = enabled @@ -817,6 +824,12 @@ constructor( } } + suspend fun setPauseOnVolumeZero(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.PAUSE_ON_VOLUME_ZERO] = enabled + } + } + // ===== End ReplayGain ===== val allowedDirectoriesFlow: Flow> = @@ -1242,12 +1255,16 @@ constructor( companion object { /** Default character delimiters for splitting multi-artist tags */ - val DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&") + val DEFAULT_ARTIST_DELIMITERS = listOf(";") + private val LEGACY_DEFAULT_ARTIST_DELIMITERS = listOf("/", ";", ",", "+", "&") /** Default word-based delimiters (matched case-insensitively with whitespace boundaries) */ val DEFAULT_ARTIST_WORD_DELIMITERS = listOf("featuring", "feat.", "feat", "ft.", "ft", "vs.", "vs", "versus", "with", "prod.", "prod") const val DEFAULT_ALBUM_ART_CACHE_LIMIT_MB = 200 } + private fun normalizeLegacyDefaultArtistDelimiters(delimiters: List): List = + if (delimiters == LEGACY_DEFAULT_ARTIST_DELIMITERS) DEFAULT_ARTIST_DELIMITERS else delimiters + val navBarCornerRadiusFlow: Flow = dataStore.data.map { preferences -> sanitizeNavBarCornerRadius(preferences[PreferencesKeys.NAV_BAR_CORNER_RADIUS] ?: 32) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt index e499b7e..6e911a7 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/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 @@ -209,8 +213,17 @@ class MusicService : MediaLibraryService() { private var shouldResumeAfterHeadsetReconnect = false private var lastNoisyPauseRealtimeMs = 0L private var resumeOnHeadsetReconnectEnabled = false + private var pauseOnVolumeZeroEnabled = false private var temporaryForegroundStartedInOnCreate = false + private val systemVolumeObserver by lazy { + object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + maybePauseForZeroSystemVolume() + } + } + } + companion object { private const val TAG = "MusicService_PixelPlayer" const val NOTIFICATION_ID = 101 @@ -397,6 +410,7 @@ class MusicService : MediaLibraryService() { controller.initialize() registerHeadsetReconnectMonitor() + registerSystemVolumeObserver() // Restore equalizer state from preferences and only attach audio effects when // the user actually has at least one effect enabled for the current session. @@ -452,6 +466,12 @@ class MusicService : MediaLibraryService() { } } + serviceScope.launch { + userPreferencesRepository.pauseOnVolumeZeroFlow.collect { enabled -> + pauseOnVolumeZeroEnabled = enabled + } + } + serviceScope.launch { userPreferencesRepository.persistentShuffleEnabledFlow.collect { enabled -> persistentShuffleEnabled = enabled @@ -1036,6 +1056,7 @@ class MusicService : MediaLibraryService() { followUpWidgetUpdateJob?.cancel() debouncedWidgetUpdateJob?.cancel() unregisterHeadsetReconnectMonitor() + unregisterSystemVolumeObserver() replayGainJob?.cancel() engine.removePlayerSwapListener(playerSwapListener) @@ -1130,6 +1151,7 @@ class MusicService : MediaLibraryService() { return } expectedReplayGainVolume = null + if (volume == 0f && maybePauseForZeroSystemVolume()) return userSelectedVolume = volume.coerceIn(0f, 1f) } @@ -1528,6 +1550,30 @@ class MusicService : MediaLibraryService() { clearHeadsetReconnectResume() } + private fun registerSystemVolumeObserver() { + contentResolver.registerContentObserver( + Settings.System.CONTENT_URI, + true, + systemVolumeObserver + ) + } + + private fun unregisterSystemVolumeObserver() { + runCatching { contentResolver.unregisterContentObserver(systemVolumeObserver) } + } + + private fun maybePauseForZeroSystemVolume(): Boolean { + if (!pauseOnVolumeZeroEnabled) return false + if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) != 0) return false + + val player = mediaSession?.player ?: engine.masterPlayer + if (!player.isPlaying) return false + + player.pause() + Timber.tag(TAG).d("pauseOnVolumeZero: paused because system media volume reached 0") + return true + } + private fun maybeResumeAfterHeadsetReconnect() { if (!resumeOnHeadsetReconnectEnabled || !shouldResumeAfterHeadsetReconnect) return diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtils.kt index 680b973..fe0d51d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtils.kt @@ -67,7 +67,9 @@ internal fun buildAlbumGroupingKeys(album: AlbumEntity): List internal fun chooseAlbumDisplayArtist( songs: List, - preferAlbumArtist: Boolean + preferAlbumArtist: Boolean, + artistDelimiters: List = emptyList(), + wordDelimiters: List = emptyList() ): String { if (songs.isEmpty()) return "Unknown Artist" @@ -78,7 +80,13 @@ internal fun chooseAlbumDisplayArtist( ) val trackArtist = mostCommonValue( songs.map { song -> - song.artistName.normalizeMetadataTextOrEmpty() + collectArtistNames( + rawArtistName = song.artistName, + title = song.title, + artistDelimiters = artistDelimiters, + wordDelimiters = wordDelimiters, + extractFromTitle = true + ).firstOrNull().normalizeMetadataTextOrEmpty() } ) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt index 6c4fae9..cc74d67 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt @@ -591,7 +591,9 @@ constructor( val representativeAlbumArt = songsInAlbum.firstNotNullOfOrNull { it.albumArtUriString } val determinedAlbumArtist = chooseAlbumDisplayArtist( songs = songsInAlbum, - preferAlbumArtist = groupByAlbumArtist + preferAlbumArtist = groupByAlbumArtist, + artistDelimiters = artistDelimiters, + wordDelimiters = wordDelimiters ) val determinedAlbumArtistId = resolveAlbumDisplayArtistId( displayArtist = determinedAlbumArtist, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt index 474ee7f..46beac6 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/player/FullPlayerContent.kt @@ -833,7 +833,7 @@ fun FullPlayerContent( onDismissLyricsSearch = { playerViewModel.resetLyricsSearchState() }, lyricsSyncOffset = lyricsSyncOffset, onLyricsSyncOffsetChange = { currentSong?.id?.let { songId -> playerViewModel.setLyricsSyncOffset(songId, it) } }, - lyricsTextStyle = MaterialTheme.typography.titleLarge, + lyricsTextStyle = MaterialTheme.typography.titleLarge.copy(fontFamily = null), colorScheme = LocalMaterialTheme.current, onBackClick = { showLyricsSheet = false }, onSaveLyricsToFile = playerViewModel::saveLyricsToFile, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/LibraryActionRow.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/LibraryActionRow.kt index 3f46962..7d48ec8 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/LibraryActionRow.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/LibraryActionRow.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.Dataset +import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -451,7 +452,9 @@ fun Breadcrumbs( modifier = Modifier.size(36.dp), enabled = currentFolder != null ) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.auth_cd_back)) + val icon = if (currentFolder == null) Icons.Rounded.Home else Icons.AutoMirrored.Rounded.ArrowBack + val description = if (currentFolder == null) R.string.tab_home else R.string.auth_cd_back + Icon(icon, contentDescription = stringResource(description)) } Spacer(Modifier.width(8.dp)) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index 56e2472..cd21ceb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -771,6 +771,13 @@ fun SettingsCategoryScreen( onValueChange = { settingsViewModel.setPlaybackSpeed(it) }, valueText = { value -> String.format(Locale.US, "%.2f×", value) } ) + SwitchSettingItem( + title = stringResource(R.string.setcat_pause_on_volume_zero_title), + subtitle = stringResource(R.string.setcat_pause_on_volume_zero_subtitle), + checked = uiState.pauseOnVolumeZero, + onCheckedChange = { settingsViewModel.setPauseOnVolumeZero(it) }, + leadingIcon = { Icon(painterResource(R.drawable.rounded_volume_down_24), null, tint = MaterialTheme.colorScheme.secondary) } + ) SwitchSettingItem( title = stringResource(R.string.setcat_hifi_mode_title), subtitle = if (uiState.hiFiModeDeviceSupported) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index d2c123c..3020e7d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -58,6 +58,7 @@ data class SettingsUiState( val libraryNavigationMode: String = LibraryNavigationMode.TAB_ROW, val launchTab: String = LaunchTab.HOME, val keepPlayingInBackground: Boolean = true, + val pauseOnVolumeZero: Boolean = false, val resumeOnHeadsetReconnect: Boolean = false, val showQueueHistory: Boolean = true, val isCrossfadeEnabled: Boolean = false, @@ -138,6 +139,7 @@ private sealed interface SettingsUiUpdate { data class Group2( val keepPlayingInBackground: Boolean, + val pauseOnVolumeZero: Boolean, val resumeOnHeadsetReconnect: Boolean, val showQueueHistory: Boolean, val isCrossfadeEnabled: Boolean, @@ -294,6 +296,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { combine( userPreferencesRepository.keepPlayingInBackgroundFlow, + userPreferencesRepository.pauseOnVolumeZeroFlow, userPreferencesRepository.resumeOnHeadsetReconnectFlow, userPreferencesRepository.showQueueHistoryFlow, userPreferencesRepository.isCrossfadeEnabledFlow, @@ -312,26 +315,28 @@ class SettingsViewModel @Inject constructor( ) { values -> SettingsUiUpdate.Group2( keepPlayingInBackground = values[0] as Boolean, - resumeOnHeadsetReconnect = values[1] as Boolean, - showQueueHistory = values[2] as Boolean, - isCrossfadeEnabled = values[3] as Boolean, - hiFiModeEnabled = values[4] as Boolean, - crossfadeDuration = values[5] as Int, - persistentShuffleEnabled = values[6] as Boolean, - folderBackGestureNavigation = values[7] as Boolean, - lyricsSourcePreference = values[8] as LyricsSourcePreference, - autoScanLrcFiles = values[9] as Boolean, - blockedDirectories = @Suppress("UNCHECKED_CAST") (values[10] as Set), - hapticsEnabled = values[11] as Boolean, - immersiveLyricsEnabled = values[12] as Boolean, - immersiveLyricsTimeout = values[13] as Long, - animatedLyricsBlurEnabled = values[14] as Boolean, - animatedLyricsBlurStrength = values[15] as Float + pauseOnVolumeZero = 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 ) }.collect { update -> _uiState.update { state -> state.copy( keepPlayingInBackground = update.keepPlayingInBackground, + pauseOnVolumeZero = update.pauseOnVolumeZero, resumeOnHeadsetReconnect = update.resumeOnHeadsetReconnect, showQueueHistory = update.showQueueHistory, isCrossfadeEnabled = update.isCrossfadeEnabled, @@ -581,6 +586,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/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index b56f239..3aa9495 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -138,6 +138,8 @@ Crossfade Enable smooth transition between songs. Crossfade Duration + Pause when volume reaches zero + Automatically pause playback when media volume is set to 0. Hi-Fi Mode Float 32-bit audio output. Disable if playback stutters on your device. Not supported on this device (PCM_FLOAT AudioTrack unavailable). diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt index 95d10a0..43853cc 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt @@ -101,4 +101,24 @@ class UserPreferencesRepositoryTest { tempDir.toFile().deleteRecursively() } } + + @Test + fun `artistDelimitersFlow normalizes legacy default delimiters`() = runTest { + val tempDir = Files.createTempDirectory("user-preferences-repository-test") + try { + val repository = UserPreferencesRepository( + dataStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } + ), + json = Json + ) + + repository.setArtistDelimiters(listOf("/", ";", ",", "+", "&")) + + assertEquals(listOf(";"), repository.artistDelimitersFlow.first()) + } finally { + tempDir.toFile().deleteRecursively() + } + } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtilsTest.kt index 95ea545..6607244 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtilsTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/AlbumGroupingUtilsTest.kt @@ -113,6 +113,24 @@ class AlbumGroupingUtilsTest { assertThat(displayArtist).isEqualTo("The Weeknd") } + @Test + fun `chooseAlbumDisplayArtist uses parsed primary artist for feature-heavy album`() { + val songs = listOf( + testSong(artistName = "Calvin Harris, Pharrell Williams, Katy Perry, Big Sean", albumArtist = null), + testSong(artistName = "Calvin Harris, Dua Lipa", albumArtist = null), + testSong(artistName = "Calvin Harris, Frank Ocean, Migos", albumArtist = null) + ) + + val displayArtist = chooseAlbumDisplayArtist( + songs = songs, + preferAlbumArtist = false, + artistDelimiters = listOf(","), + wordDelimiters = emptyList() + ) + + assertThat(displayArtist).isEqualTo("Calvin Harris") + } + @Test fun `chooseAlbumDisplayArtist prefers album artist when grouping is on`() { val songs = listOf( diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt index 6fe9e1c..f98f19e 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt @@ -1,10 +1,51 @@ package com.lostf1sh.pixelplayeross.data.worker +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class ArtistParsingUtilsTest { + @Test + fun `default delimiters preserve ampersand slash comma and plus inside artist names`() { + assertEquals( + listOf("W&W"), + collectArtistNames( + rawArtistName = "W&W", + title = "Rave Culture", + artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS, + wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS + ) + ) + assertEquals( + listOf("AC/DC"), + collectArtistNames( + rawArtistName = "AC/DC", + title = "Back In Black", + artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS, + wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS + ) + ) + assertEquals( + listOf("Lost & Found"), + collectArtistNames( + rawArtistName = "Lost & Found", + title = "Found", + artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS, + wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS + ) + ) + assertEquals( + listOf("Black Country, New Road"), + collectArtistNames( + rawArtistName = "Black Country, New Road", + title = "Track X", + artistDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS, + wordDelimiters = UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS + ) + ) + } + @Test fun `choosePreferredArtistName prefers media store when it contains more artists`() { val result =