Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -112,6 +116,6 @@ fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity {
mimeType = resolvedMimeType,
suffix = suffix,
path = path,
dateAdded = System.currentTimeMillis()
dateAdded = dateAddedOr(nowMs)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -1342,7 +1345,7 @@ class DualPlayerEngine @Inject constructor(
} catch (e: Exception) { }
}
if (::playerA.isInitialized) {
playerA.volume = 1f
restoreVolumeBeforeTransition()
if (shouldPublishMasterPlayer) {
onPlayerSwappedListeners.forEach { it(playerA) }
}
Expand All @@ -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
Comment on lines +1362 to +1364

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Volume restoration lacks tests The new saved-volume logic has no automated test for cancelling before or during a crossfade, or for restoring volume after a failure or player rebuild. A test starting at a ReplayGain-adjusted volume would help catch a return of the queue-edit volume jump or restoration to the wrong level.

Knowledge Base Used: Audio processing and equalizer

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt
Line: 1362-1364

Comment:
**Volume restoration lacks tests** The new saved-volume logic has no automated test for cancelling before or during a crossfade, or for restoring volume after a failure or player rebuild. A test starting at a ReplayGain-adjusted volume would help catch a return of the queue-edit volume jump or restoration to the wrong level.

**Knowledge Base Used:** [Audio processing and equalizer](https://app.greptile.com/moli/-/custom-context/knowledge-base/pixelplayerhq/pixelplayeross/-/docs/audio-processing-and-equalizer.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bro dwbi, we dont need tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood — given the emulator coverage and scope of this fix, I’ll treat the missing automated crossfade-cancellation tests as acceptable here. No further action needed.

}

fun performTransition(settings: TransitionSettings) {
transitionJob?.cancel()
if (volumeBeforeTransition == null) volumeBeforeTransition = playerA.volume
val transitionRunId = transitionRunTracker.start()
transitionRunning = true
auxiliaryPlayerPresented = false
Expand All @@ -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()
}
Expand All @@ -1383,21 +1397,21 @@ 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
}

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
Expand Down Expand Up @@ -1433,6 +1447,7 @@ class DualPlayerEngine @Inject constructor(
outgoingPlayer.volume = 0f
incomingPlayer.volume = incomingTrackReplayGainVolume ?: 1f
incomingTrackReplayGainVolume = null
volumeBeforeTransition = null

removeMasterPlayerListeners(outgoingPlayer)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -2271,7 +2271,7 @@ private fun LibraryNavigationCompactTitle(
}

Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape,
modifier = Modifier
.align(Alignment.CenterStart)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading