Restore Media3 Next behavior (#8560)

### Description

Restore legacy manual Next behavior in the Media3 playback service.

This PR advertises the standard Media3 NEXT command and routes it
through the Media3 next-in-queue path with legacy-equivalent playback
finalization. That restores legacy queue cleanup, episode played-state
handling, playback history, auto-delete evaluation, and end-of-queue
behavior for manual Next.

While implementing this, several related Media3 playback synchronization
issues were also addressed:

- Route playback state synchronization through
`updatePlaybackPreferences()` after switching episodes so
`PlayerStatusEvent` is emitted through the centralized helper and the
playback UI immediately reflects the new media.
- Clear the current playable when skipping the final queue item so the
service, player, widget, and notification do not retain stale playback
state.
- Restore the existing end-of-queue message when manually skipping the
final queued episode.
- Use Media3 media button preferences instead of the legacy custom
layout API so the standard NEXT command can be exposed while preserving
the intended notification/SystemUI button arrangement.

Closes #8528

### Checklist
- [x] I have read the contribution guidelines:
https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request
- [x] I have performed a self-review of my code, going through my
changes line by line and carefully considering why this line change is
necessary
- [x] I have run the automated code checks using `./gradlew checkstyle
lint`
- [x] My code follows the style guidelines of the AntennaPod project:
https://antennapod.org/contribute/develop/app/code-style
- [x] I have mentioned the corresponding issue and the relevant keyword
(e.g., "Closes: #xy") in the description (see
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [ ] If it is a core feature, I have added automated tests
This commit is contained in:
Geoffrey Sisco
2026-07-11 17:22:03 -04:00
committed by GitHub
parent 42b0ef5e27
commit c3c2ab61d7
2 changed files with 37 additions and 24 deletions

View File

@ -22,6 +22,8 @@ import androidx.media3.session.SessionCommand;
import androidx.media3.session.SessionResult;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import de.danoeh.antennapod.event.MessageEvent;
import de.danoeh.antennapod.event.FeedItemEvent;
import de.danoeh.antennapod.event.PlayerErrorEvent;
import de.danoeh.antennapod.event.StreamingConfirmationEvent;
@ -120,6 +122,7 @@ public class Media3PlaybackService extends MediaLibraryService {
public Player.Commands getAvailableCommands() {
return super.getAvailableCommands()
.buildUpon()
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build();
@ -165,7 +168,7 @@ public class Media3PlaybackService extends MediaLibraryService {
@Override
public void seekToNextMediaItem() {
if (currentPlayable != null) {
startNextInQueue(currentPlayable.getItem());
startNextInQueue(currentPlayable, true, false);
}
}
@ -193,9 +196,6 @@ public class Media3PlaybackService extends MediaLibraryService {
if (customCommand.customAction.equals(SESSION_COMMAND_PLAYBACK_SPEED.customAction)) {
setNextPlaybackSpeed();
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
} else if (customCommand.customAction.equals(SESSION_COMMAND_SKIP_TO_NEXT.customAction)) {
session.getPlayer().seekToNextMediaItem();
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
} else if (customCommand.customAction.equals(SESSION_COMMAND_NEXT_CHAPTER.customAction)) {
seekToNextChapter();
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
@ -257,10 +257,10 @@ public class Media3PlaybackService extends MediaLibraryService {
if (playbackState == Player.STATE_ENDED && currentPlayable != null) {
FeedMedia media = currentPlayable;
currentPlayable = null; // To avoid position updater saving position after we already reset it
onPlaybackEnd(media);
if (sleepTimer != null && sleepTimer.isActive()) {
sleepTimer.episodeFinishedPlayback();
if (!sleepTimer.shouldContinueToNextEpisode()) {
finalizePlayback(media, true, false, false);
player.stop();
player.clearMediaItems();
PlaybackPreferences.writeNoMediaPlaying();
@ -270,7 +270,7 @@ public class Media3PlaybackService extends MediaLibraryService {
return;
}
}
startNextInQueue(media.getItem());
startNextInQueue(media, false, true);
}
}
@ -501,7 +501,7 @@ public class Media3PlaybackService extends MediaLibraryService {
PlayableUtils.saveCurrentPosition(currentPlayable, (int) position, timestamp);
}
private void onPlaybackEnd(FeedMedia media) {
private void finalizePlayback(FeedMedia media, boolean ended, boolean skipped, boolean playingNext) {
if (media == null) {
return;
}
@ -511,11 +511,13 @@ public class Media3PlaybackService extends MediaLibraryService {
boolean almostEnded = media.getDuration() > 0
&& media.getPosition() >= media.getDuration() - smartMarkAsPlayedSecs * 1000;
SynchronizationQueue.getInstance().enqueueEpisodePlayed(media, almostEnded);
if (almostEnded) {
if (item != null) {
SynchronizationQueue.getInstance().enqueueEpisodePlayed(media, ended || almostEnded);
if (item != null) {
if (ended || almostEnded) {
DBWriter.markItemsPlayed(FeedItem.PLAYED, true, Collections.singletonList(item));
DBWriter.removeQueueItem(this, true, item);
}
if (ended || almostEnded || (skipped && !UserPreferences.shouldSkipKeepEpisode())) {
DBWriter.removeQueueItem(this, ended, item);
FeedPreferences.AutoDeleteAction action = item.getFeed().getPreferences().getCurrentAutoDelete();
boolean autoDeleteEnabledGlobally = UserPreferences.isAutoDelete()
&& (!item.getFeed().isLocalFeed() || UserPreferences.isAutoDeleteLocal());
@ -526,6 +528,8 @@ public class Media3PlaybackService extends MediaLibraryService {
DBWriter.deleteFeedMediaOfItem(this, media);
}
}
}
if (ended || skipped || playingNext) {
DBWriter.addItemToPlaybackHistory(media);
}
}
@ -560,7 +564,7 @@ public class Media3PlaybackService extends MediaLibraryService {
List<Chapter> chapters = currentPlayable.getChapters();
if (chapters == null) {
if (currentPlayable.getItem() != null) {
startNextInQueue(currentPlayable.getItem());
startNextInQueue(currentPlayable, true, false);
}
return;
}
@ -569,7 +573,7 @@ public class Media3PlaybackService extends MediaLibraryService {
if (chapters.size() < nextChapter + 1) {
if (currentPlayable.getItem() != null) {
startNextInQueue(currentPlayable.getItem());
startNextInQueue(currentPlayable, true, false);
}
return;
}
@ -627,17 +631,24 @@ public class Media3PlaybackService extends MediaLibraryService {
* Loads the next item, and starts it if continuous playback is enabled.
*/
@UnstableApi
private void startNextInQueue(FeedItem item) {
private void startNextInQueue(FeedMedia media, boolean wasSkipped, boolean ended) {
if (queueLoaderDisposable != null) {
queueLoaderDisposable.dispose();
}
if (media == null) {
return;
}
FeedItem item = media.getItem();
if (item == null) {
return;
}
queueLoaderDisposable = Maybe.fromCallable(() -> {
FeedItem nextItem = DBReader.getNextInQueue(item);
if (nextItem != null && nextItem.getMedia() != null) {
return new Pair<>(nextItem.getMedia(), MediaItemAdapter.fromPlayable(Media3PlaybackService.this, nextItem.getMedia(), false));
boolean hasNext = nextItem != null && nextItem.getMedia() != null;
finalizePlayback(media, ended, wasSkipped, hasNext);
if (hasNext) {
return new Pair<>(nextItem.getMedia(),
MediaItemAdapter.fromPlayable(Media3PlaybackService.this, nextItem.getMedia(), false));
}
return null;
})
@ -656,7 +667,7 @@ public class Media3PlaybackService extends MediaLibraryService {
currentPlayable = nextMedia;
currentPlayable.onPlaybackStart();
PlaybackPreferences.writeMediaPlaying(nextMedia);
updatePlaybackPreferences();
if (nextMedia.getItem() != null && nextMedia.getItem().getFeed() != null) {
volumeAdaptionFactor = nextMedia.getItem().getFeed()
.getPreferences().getVolumeAdaptionSetting().getAdaptionFactor();
@ -669,12 +680,16 @@ public class Media3PlaybackService extends MediaLibraryService {
},
error -> Log.e(TAG, "Failed to load next queue item", error),
() -> {
currentPlayable = null;
player.stop();
player.clearMediaItems();
PlaybackPreferences.writeNoMediaPlaying();
EventBus.getDefault().post(new PlayerStatusEvent());
EventBus.getDefault().post(
new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN));
if (wasSkipped) {
EventBus.getDefault().post(new MessageEvent(getString(R.string.no_following_in_queue)));
}
});
}

View File

@ -65,8 +65,6 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib
= new SessionCommand("fast_forward", Bundle.EMPTY);
protected static final SessionCommand SESSION_COMMAND_PLAYBACK_SPEED
= new SessionCommand("playback_speed", Bundle.EMPTY);
protected static final SessionCommand SESSION_COMMAND_SKIP_TO_NEXT
= new SessionCommand("skip_to_next", Bundle.EMPTY);
protected static final SessionCommand SESSION_COMMAND_NEXT_CHAPTER
= new SessionCommand("next_chapter", Bundle.EMPTY);
public static final SessionCommand SESSION_COMMAND_SKIP_SILENCE
@ -116,7 +114,6 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib
.add(SESSION_COMMAND_REWIND)
.add(SESSION_COMMAND_FAST_FORWARD)
.add(SESSION_COMMAND_PLAYBACK_SPEED)
.add(SESSION_COMMAND_SKIP_TO_NEXT)
.add(SESSION_COMMAND_NEXT_CHAPTER)
.add(SESSION_COMMAND_SKIP_SILENCE)
.add(SESSION_COMMAND_SET_SLEEP_TIMER)
@ -130,7 +127,7 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib
.build();
return new MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.setCustomLayout(buildCustomLayout())
.setMediaButtonPreferences(buildCustomLayout())
.setAvailablePlayerCommands(playerCommands)
.build();
}
@ -138,12 +135,12 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib
@Override
@UnstableApi
public void onPostConnect(@NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
session.setCustomLayout(buildCustomLayout());
session.setMediaButtonPreferences(buildCustomLayout());
}
@UnstableApi
public void refreshNotification(MediaLibraryService.MediaLibrarySession session) {
session.setCustomLayout(buildCustomLayout());
session.setMediaButtonPreferences(buildCustomLayout());
}
@UnstableApi
@ -178,7 +175,8 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib
if (UserPreferences.showSkipOnFullNotification()) {
buttons.add(new CommandButton.Builder(CommandButton.ICON_NEXT)
.setSessionCommand(SESSION_COMMAND_SKIP_TO_NEXT)
.setSlots(CommandButton.SLOT_OVERFLOW)
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.setDisplayName(context.getString(R.string.skip_episode_label))
.build());
}