From 1eb6c1a3bd083c451569bd9008b854b6a749dc88 Mon Sep 17 00:00:00 2001 From: Duhan <136324426+lostf1sh@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:56:18 +0300 Subject: [PATCH 1/3] Fix compact library title pill rendering black (#134) --- .../pixelplayeross/presentation/screens/LibraryScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index c8b0436a..7d0e0fb0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt @@ -2235,13 +2235,13 @@ private fun LibraryNavigationCompactTitle( } } - val primaryColor = MaterialTheme.colorScheme.primary + val titleColor = MaterialTheme.colorScheme.onPrimaryContainer val finalTextStyle = remember( animatedWidthAxis, targetFontSize, targetLetterSpacing, targetWeight, - primaryColor + titleColor ) { TextStyle( fontFamily = FontFamily( @@ -2261,7 +2261,7 @@ private fun LibraryNavigationCompactTitle( fontSize = targetFontSize, lineHeight = targetFontSize, letterSpacing = targetLetterSpacing, - color = primaryColor, + color = titleColor, platformStyle = PlatformTextStyle(includeFontPadding = false), lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, @@ -2271,7 +2271,7 @@ private fun LibraryNavigationCompactTitle( } Surface( - color = MaterialTheme.colorScheme.surfaceContainerLowest, + color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape, modifier = Modifier .align(Alignment.CenterStart) From 1abeeeceb83c092418aeb084a708cc73fb3f0930 Mon Sep 17 00:00:00 2001 From: Duhan <136324426+lostf1sh@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:56:18 +0300 Subject: [PATCH 2/3] Use server created time for Navidrome date added (#132) --- .../data/database/NavidromeSongEntity.kt | 10 +++-- .../data/navidrome/NavidromeRepository.kt | 2 +- .../data/navidrome/model/NavidromeSong.kt | 13 ++++++- .../navidrome/NavidromeResponseParser.kt | 3 +- .../data/database/NavidromeSongEntityTest.kt | 26 +++++++++++++ .../navidrome/NavidromeResponseParserTest.kt | 37 +++++++++++++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParserTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt index 797c3d99..89a29197 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt @@ -28,7 +28,8 @@ import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeSong * @property mimeType The MIME type * @property suffix The file suffix (mp3, flac, etc.) * @property path The file path on the server - * @property dateAdded The timestamp when this record was added + * @property dateAdded When the song was added to the server library (epoch millis), falling back + * to the time it was cached when the server does not report it */ @Entity( tableName = "navidrome_songs", @@ -91,7 +92,10 @@ fun NavidromeSongEntity.toSong(): Song { /** * Convert a [NavidromeSong] to a [NavidromeSongEntity] for database storage. */ -fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity { +fun NavidromeSong.toEntity( + playlistId: String, + nowMs: Long = System.currentTimeMillis() +): NavidromeSongEntity { return NavidromeSongEntity( id = "${playlistId}_$id", navidromeId = id, @@ -112,6 +116,6 @@ fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity { mimeType = resolvedMimeType, suffix = suffix, path = path, - dateAdded = System.currentTimeMillis() + dateAdded = dateAddedOr(nowMs) ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt index da16212a..7305a0d2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt @@ -1052,7 +1052,7 @@ fun NavidromeSong.toSong(): Song { sampleRate = null, year = year, trackNumber = trackNumber, - dateAdded = System.currentTimeMillis(), + dateAdded = dateAddedOr(System.currentTimeMillis()), isFavorite = false, navidromeId = id ) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt index 3f897ef6..6155bc36 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt @@ -27,6 +27,7 @@ import kotlinx.parcelize.Parcelize * @property path The file path on the server * @property size The file size in bytes (optional) * @property playCount The play count (optional) + * @property created When the song was added to the server library, in epoch milliseconds (0 if unknown) */ @Immutable @Parcelize @@ -49,7 +50,8 @@ data class NavidromeSong( val suffix: String? = null, val path: String = "", val size: Long? = null, - val playCount: Int = 0 + val playCount: Int = 0, + val created: Long = 0L ) : Parcelable { companion object { fun empty() = NavidromeSong( @@ -71,10 +73,17 @@ data class NavidromeSong( suffix = null, path = "", size = null, - playCount = 0 + playCount = 0, + created = 0L ) } + /** + * The date-added timestamp to store locally: the server's `created` time, or [fallbackMs] + * when the server did not report one. + */ + fun dateAddedOr(fallbackMs: Long): Long = created.takeIf { it > 0L } ?: fallbackMs + /** * Returns the MIME type, with fallback based on file suffix. */ diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt index 7beb9769..037bf185 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt @@ -98,7 +98,8 @@ object NavidromeResponseParser { suffix = json.optString("suffix").takeIf { it.isNotEmpty() }, path = json.optString("path", ""), size = json.optLong("size", 0).takeIf { it > 0 }, - playCount = json.optInt("playCount", 0) + playCount = json.optInt("playCount", 0), + created = parseTimestamp(json.optString("created")) ) } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntityTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntityTest.kt index ad45cd54..6153d149 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntityTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntityTest.kt @@ -1,6 +1,7 @@ package com.lostf1sh.pixelplayeross.data.database import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeSong import org.junit.jupiter.api.Test class NavidromeSongEntityTest { @@ -35,4 +36,29 @@ class NavidromeSongEntityTest { assertThat(song.navidromeId).isEqualTo("song-1") assertThat(song.contentUriString).isEqualTo("navidrome://song-1") } + + private fun navidromeSong(created: Long) = NavidromeSong( + id = "song-1", + title = "Track", + artist = "Artist", + album = "Album", + duration = 180_000L, + created = created + ) + + @Test + fun `toEntity uses server created time as date added`() { + val entity = navidromeSong(created = 1_700_000_000_000L) + .toEntity(playlistId = "__library__", nowMs = 1_800_000_000_000L) + + assertThat(entity.dateAdded).isEqualTo(1_700_000_000_000L) + } + + @Test + fun `toEntity falls back to sync time when server created time is missing`() { + val entity = navidromeSong(created = 0L) + .toEntity(playlistId = "__library__", nowMs = 1_800_000_000_000L) + + assertThat(entity.dateAdded).isEqualTo(1_800_000_000_000L) + } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParserTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParserTest.kt new file mode 100644 index 00000000..3097d53d --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParserTest.kt @@ -0,0 +1,37 @@ +package com.lostf1sh.pixelplayeross.data.network.navidrome + +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.Test + +class NavidromeResponseParserTest { + + private fun song(created: String?) = JSONObject().apply { + put("id", "song-1") + put("title", "Track") + put("artist", "Artist") + put("album", "Album") + put("duration", 180) + if (created != null) put("created", created) + } + + @Test + fun `song created timestamp is parsed to epoch millis`() { + val parsed = NavidromeResponseParser.parseSong(song("2024-03-15T10:20:30.123Z")) + + assertThat(parsed.created).isEqualTo(1_710_498_030_123L) + } + + @Test + fun `song created timestamp with nanosecond precision is parsed`() { + val parsed = NavidromeResponseParser.parseSong(song("2024-03-15T10:20:30.123456789Z")) + + assertThat(parsed.created).isEqualTo(1_710_498_030_123L) + } + + @Test + fun `song without created timestamp reports zero`() { + assertThat(NavidromeResponseParser.parseSong(song(null)).created).isEqualTo(0L) + assertThat(NavidromeResponseParser.parseSong(song("not-a-date")).created).isEqualTo(0L) + } +} From 1880ba418d4829daa1ce880bd03cf42296a5ac51 Mon Sep 17 00:00:00 2001 From: Duhan <136324426+lostf1sh@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:11:05 +0300 Subject: [PATCH 3/3] Keep ReplayGain volume when queue edits cancel a crossfade (#133) --- .../data/service/player/DualPlayerEngine.kt | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt index 64287671..86e3d386 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt @@ -347,6 +347,9 @@ class DualPlayerEngine @Inject constructor( */ var incomingTrackReplayGainVolume: Float? = null + /** Master volume (ReplayGain or user) captured when a crossfade starts; null otherwise. */ + private var volumeBeforeTransition: Float? = null + private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { @@ -892,7 +895,7 @@ class DualPlayerEngine @Inject constructor( val transitionWasRunning = transitionRunning val stableVolume = when { useAuxiliaryPlayer -> incomingTrackReplayGainVolume ?: 1f - transitionWasRunning -> 1f + transitionWasRunning -> volumeBeforeTransition ?: sourcePlayer.volume else -> sourcePlayer.volume } @@ -922,7 +925,7 @@ class DualPlayerEngine @Inject constructor( resetPreparedWindowState() incomingTrackReplayGainVolume = null if (::playerA.isInitialized) { - playerA.volume = 1f + restoreVolumeBeforeTransition() playerA.pauseAtEndOfMediaItems = false } Timber.tag("TransitionDebug").d("Cancelled active transition before rebuilding players.") @@ -1342,7 +1345,7 @@ class DualPlayerEngine @Inject constructor( } catch (e: Exception) { } } if (::playerA.isInitialized) { - playerA.volume = 1f + restoreVolumeBeforeTransition() if (shouldPublishMasterPlayer) { onPlayerSwappedListeners.forEach { it(playerA) } } @@ -1351,8 +1354,19 @@ class DualPlayerEngine @Inject constructor( setPauseAtEndOfMediaItems(false) } + /** + * Puts the master player back at the volume it had before a crossfade started fading it. + * Leaves it untouched when no crossfade ran, so ReplayGain or user volume survives queue + * edits and other cancellations. + */ + private fun restoreVolumeBeforeTransition() { + volumeBeforeTransition?.let { playerA.volume = it } + volumeBeforeTransition = null + } + fun performTransition(settings: TransitionSettings) { transitionJob?.cancel() + if (volumeBeforeTransition == null) volumeBeforeTransition = playerA.volume val transitionRunId = transitionRunTracker.start() transitionRunning = true auxiliaryPlayerPresented = false @@ -1364,7 +1378,7 @@ class DualPlayerEngine @Inject constructor( } catch (e: Exception) { if (transitionRunTracker.isCurrent(transitionRunId)) { Timber.tag("TransitionDebug").e(e, "Error performing transition") - playerA.volume = 1f + restoreVolumeBeforeTransition() setPauseAtEndOfMediaItems(false) playerB?.stop() } @@ -1383,7 +1397,7 @@ class DualPlayerEngine @Inject constructor( private suspend fun performOverlapTransition(settings: TransitionSettings) { val auxiliaryPlayer = playerB if (auxiliaryPlayer == null || auxiliaryPlayer.mediaItemCount == 0) { - playerA.volume = 1f + restoreVolumeBeforeTransition() setPauseAtEndOfMediaItems(false) return } @@ -1391,13 +1405,13 @@ class DualPlayerEngine @Inject constructor( if (auxiliaryPlayer.playbackState == Player.STATE_IDLE) auxiliaryPlayer.prepare() if (auxiliaryPlayer.playbackState == Player.STATE_BUFFERING) { if (!awaitPlayerReady(auxiliaryPlayer, 3000L)) { - playerA.volume = 1f + restoreVolumeBeforeTransition() setPauseAtEndOfMediaItems(false) return } } - val outgoingStartVolume = playerA.volume.coerceIn(0f, 1f) + val outgoingStartVolume = (volumeBeforeTransition ?: playerA.volume).coerceIn(0f, 1f) auxiliaryPlayer.volume = 0f if (!playerA.isPlaying && playerA.playbackState == Player.STATE_READY) playerA.play() auxiliaryPlayer.playWhenReady = true @@ -1433,6 +1447,7 @@ class DualPlayerEngine @Inject constructor( outgoingPlayer.volume = 0f incomingPlayer.volume = incomingTrackReplayGainVolume ?: 1f incomingTrackReplayGainVolume = null + volumeBeforeTransition = null removeMasterPlayerListeners(outgoingPlayer)