@@ -125,6 +125,15 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
125125 */
126126 private var wasPlayingBeforeTransition: Boolean = false
127127
128+ /* *
129+ * Holds the media ID of a song that needs its play recorded once the player actually
130+ * starts playing. This happens when a new queue is set — ExoPlayer fires the item
131+ * transition callback before [Player.play] is called, so [Player.playWhenReady] is
132+ * still false at that moment and the play would be silently missed. We park the ID here
133+ * and flush it as soon as playback begins.
134+ */
135+ private var pendingPlayRecordMediaId: String? = null
136+
128137 /* *
129138 * The duration of the song that is CURRENTLY loaded and ready to play, kept fresh by
130139 * reading [player.duration] once the player reaches STATE_READY (the only state where
@@ -838,6 +847,24 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
838847 }
839848
840849 override fun onPlayWhenReadyChanged (playWhenReady : Boolean , reason : Int ) {
850+ if (playWhenReady) {
851+ // If there's a song that was waiting for playback to start, record its play now.
852+ // This covers the case where a new queue is created and play() is called after
853+ // the item transition callback already fired (with playWhenReady still false).
854+ val deferredMediaId = pendingPlayRecordMediaId
855+ if (deferredMediaId != null ) {
856+ pendingPlayRecordMediaId = null
857+ val audioId = deferredMediaId.toLongOrNull()
858+ if (audioId != null ) {
859+ serviceScope.launch(Dispatchers .IO ) {
860+ val audio = audioRepository.getAudioById(audioId) ? : return @launch
861+ songStatRepository.recordPlay(audio.hash)
862+ Log .d(TAG , " Deferred play recorded for: ${audio.title} " )
863+ }
864+ }
865+ }
866+ }
867+
841868 if (AudioPreferences .isGaplessPlaybackEnabled().not ()) {
842869 if (! playWhenReady && reason == Player .PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM ) {
843870 // The track ended and the player paused itself automatically.
@@ -999,7 +1026,13 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
9991026 // Also check whether this was a backward navigation and count it as a replay if so.
10001027 mediaItem?.let { item ->
10011028 previousItemMediaId = item.mediaId
1002- if (player.playWhenReady) {
1029+ // While a queue replacement is in flight, ExoPlayer fires intermediate transitions
1030+ // (e.g. the first item of the new list lands before seekTo moves to the real target).
1031+ // We must not record those as plays — instead park the ID so it can be flushed once
1032+ // the replacement is complete and the intended song actually starts playing.
1033+ val replacingQueue = MediaPlaybackManager .isQueueBeingReplaced
1034+ if (player.playWhenReady && ! replacingQueue) {
1035+ pendingPlayRecordMediaId = null
10031036 val audioId = item.mediaId.toLongOrNull() ? : return @let
10041037 val isBackwardNavigation = ! MediaPlaybackManager .lastNavigationDirection
10051038 serviceScope.launch(Dispatchers .IO ) {
@@ -1014,11 +1047,36 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
10141047 }
10151048 }
10161049 } else {
1017- Log .d(TAG , " Track changed while paused — skipping play stat for: ${item.mediaId} " )
1050+ // Either the player isn't set to play yet, or the queue is still being
1051+ // replaced — park the ID and wait for playback to actually begin.
1052+ pendingPlayRecordMediaId = item.mediaId
1053+ Log .d(TAG , " Deferring play stat for: ${item.mediaId} (playWhenReady=${player.playWhenReady} , replacingQueue=$replacingQueue )" )
10181054 }
1019- } ? : run { previousItemMediaId = null }
1055+ } ? : run {
1056+ previousItemMediaId = null
1057+ pendingPlayRecordMediaId = null
1058+ }
10201059
10211060 MediaPlaybackManager .notifyCurrentPosition(player.currentMediaItemIndex)
1061+
1062+ // After notifyCurrentPosition runs, the queue-replacement guard may have just been
1063+ // lifted (it clears itself once ExoPlayer confirms the intended seek position).
1064+ // If the player is already set to play, and we still have a parked ID, this is our
1065+ // window to flush it — onPlayWhenReadyChanged won't fire because playWhenReady
1066+ // never went false during an already-playing queue swap.
1067+ val deferredId = pendingPlayRecordMediaId
1068+ if (deferredId != null && player.playWhenReady && ! MediaPlaybackManager .isQueueBeingReplaced) {
1069+ pendingPlayRecordMediaId = null
1070+ val audioId = deferredId.toLongOrNull()
1071+ if (audioId != null ) {
1072+ serviceScope.launch(Dispatchers .IO ) {
1073+ val audio = audioRepository.getAudioById(audioId) ? : return @launch
1074+ songStatRepository.recordPlay(audio.hash)
1075+ Log .d(TAG , " Deferred play flushed (post-replace) for: ${audio.title} " )
1076+ }
1077+ }
1078+ }
1079+
10221080 savePlaybackStateToDatabase() // Save when track changes
10231081 buildAndPushSnapshot()
10241082 broadcastWidgetUpdate()
0 commit comments