capacitor-plugin-playlist 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +133 -127
  2. package/android/build.gradle +31 -18
  3. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  4. package/android/src/main/java/org/dwbn/plugins/playlist/AudioMediaItemFactory.java +96 -0
  5. package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +3 -3
  6. package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioPlayer.java +145 -188
  7. package/android/src/main/java/org/dwbn/plugins/playlist/RmxPlaybackErrorMapper.kt +26 -0
  8. package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +28 -28
  9. package/android/src/main/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridge.kt +102 -0
  10. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaybackProgress.kt +8 -0
  11. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +418 -183
  12. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicy.kt +98 -0
  13. package/android/src/main/java/org/dwbn/plugins/playlist/manager/RmxPlaybackState.kt +11 -0
  14. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandler.java +107 -211
  15. package/android/src/main/java/org/dwbn/plugins/playlist/service/GlideBitmapLoader.kt +121 -0
  16. package/android/src/main/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayer.kt +185 -0
  17. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicy.kt +253 -0
  18. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaService.kt +260 -68
  19. package/android/src/main/java/org/dwbn/plugins/playlist/service/PlaylistMediaSessionCallback.kt +39 -0
  20. package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryMimeTest.kt +98 -0
  21. package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryTest.java +71 -0
  22. package/android/src/test/java/org/dwbn/plugins/playlist/data/AudioTrackTest.kt +53 -0
  23. package/android/src/test/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridgeTest.kt +84 -0
  24. package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicyTest.kt +128 -0
  25. package/android/src/test/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandlerTest.kt +145 -0
  26. package/android/src/test/java/org/dwbn/plugins/playlist/service/GlideBitmapLoaderTest.kt +76 -0
  27. package/android/src/test/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayerTest.kt +282 -0
  28. package/android/src/test/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicyTest.kt +285 -0
  29. package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationCommandsTest.kt +54 -0
  30. package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationPolicyTest.kt +47 -0
  31. package/dist/docs.json +12 -846
  32. package/dist/esm/RmxAudioPlayer.d.ts +2 -2
  33. package/dist/esm/RmxAudioPlayer.js.map +1 -1
  34. package/dist/esm/definitions.d.ts +7 -7
  35. package/dist/esm/interfaces.d.ts +7 -1
  36. package/dist/esm/utils.d.ts +1 -1
  37. package/dist/esm/utils.js +2 -2
  38. package/dist/esm/utils.js.map +1 -1
  39. package/dist/esm/web.d.ts +3 -3
  40. package/dist/esm/web.js +5 -5
  41. package/dist/esm/web.js.map +1 -1
  42. package/dist/plugin.cjs.js +7 -7
  43. package/dist/plugin.cjs.js.map +1 -1
  44. package/dist/plugin.js +7 -7
  45. package/dist/plugin.js.map +1 -1
  46. package/ios/Sources/PlaylistPlugin/AVBidirectionalQueuePlayer.swift +45 -6
  47. package/ios/Sources/PlaylistPlugin/AudioTrack.swift +15 -2
  48. package/ios/Tests/PlaylistPluginTests/PositionResumeTests.swift +82 -0
  49. package/package.json +15 -19
  50. package/android/src/main/java/org/dwbn/plugins/playlist/notification/PlaylistNotificationProvider.kt +0 -26
  51. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioApi.kt +0 -114
  52. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/BaseMediaApi.kt +0 -36
  53. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaImageProvider.kt +0 -83
@@ -0,0 +1,98 @@
1
+ package org.dwbn.plugins.playlist.manager
2
+
3
+ import androidx.media3.common.Player
4
+
5
+ /**
6
+ * Playback decisions that keep the Media3 playlist behaving like the former PlaylistCore stack.
7
+ * Pure functions so JVM tests pin the parity rules.
8
+ */
9
+ object PlaylistPlaybackPolicy {
10
+ /** PlaylistCore skipped a failing item up to this many times in a row before giving up. */
11
+ const val MAX_SEQUENTIAL_ERRORS = 3
12
+
13
+ /**
14
+ * PlaylistCore never repeated a single item: with one item `next` was unavailable even when
15
+ * looping, so playback stopped at its end. Media3 `REPEAT_MODE_ALL` would restart it forever.
16
+ */
17
+ @JvmStatic
18
+ fun repeatMode(loop: Boolean, itemCount: Int): Int =
19
+ if (loop && itemCount > 1) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
20
+
21
+ @JvmStatic
22
+ fun nextAvailable(index: Int, itemCount: Int, loop: Boolean): Boolean {
23
+ if (itemCount <= 1) {
24
+ return false
25
+ }
26
+ val isAtEnd = index + 1 >= itemCount
27
+ return if (isAtEnd) loop else index + 1 in 0 until itemCount
28
+ }
29
+
30
+ /**
31
+ * Window index for [Player.seekTo] after a failed item, matching [PlaylistManager.skipFailedItem]:
32
+ * next item, or wrap to 0 at the end of the list.
33
+ */
34
+ @JvmStatic
35
+ fun errorSkipWindowIndex(failedIndex: Int, itemCount: Int): Int {
36
+ if (itemCount <= 0) {
37
+ return 0
38
+ }
39
+ return if (failedIndex + 1 >= itemCount) 0 else failedIndex + 1
40
+ }
41
+
42
+ @JvmStatic
43
+ fun previousAvailable(index: Int, loop: Boolean, itemCount: Int): Boolean {
44
+ if (itemCount <= 0) {
45
+ return false
46
+ }
47
+ return index > 0 || loop
48
+ }
49
+
50
+ /**
51
+ * Window index for [Player.seekTo] when skipping forward, matching [PlaylistManager.skipToNext]:
52
+ * next item, or wrap to 0 at the end of the list when looping.
53
+ */
54
+ @JvmStatic
55
+ fun nextSkipIndex(fromIndex: Int, itemCount: Int, loop: Boolean): Int =
56
+ if (fromIndex + 1 >= itemCount && loop) 0 else fromIndex + 1
57
+
58
+ /**
59
+ * Window index for [Player.seekTo] when skipping backward, matching
60
+ * [PlaylistManager.skipToPrevious]: previous item, or wrap to the last item when looping.
61
+ */
62
+ @JvmStatic
63
+ fun previousSkipIndex(fromIndex: Int, itemCount: Int, loop: Boolean): Int =
64
+ if (fromIndex <= 0 && loop) itemCount - 1 else fromIndex - 1
65
+
66
+ /** An item played to its end and the player moved on by itself (not a seek or list change). */
67
+ @JvmStatic
68
+ fun isNaturalItemEnd(transitionReason: Int): Boolean =
69
+ transitionReason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
70
+ transitionReason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT
71
+
72
+ @JvmStatic
73
+ fun shouldSkipAfterError(sequentialErrors: Int, nextAvailable: Boolean): Boolean =
74
+ nextAvailable && sequentialErrors <= MAX_SEQUENTIAL_ERRORS
75
+
76
+ /**
77
+ * PlaylistCore assumed the user had pressed play for every item after the first, so it kept
78
+ * playing after skipping a broken item unless the failure hit the auto-buffered first item.
79
+ */
80
+ @JvmStatic
81
+ fun playWhenReadyAfterErrorSkip(playWhenReady: Boolean, failedIndex: Int): Boolean =
82
+ playWhenReady || failedIndex > 0
83
+
84
+ /** Media3 ignores play on an idle or ended player; it must be prepared or rewound first. */
85
+ @JvmStatic
86
+ fun needsPrepareBeforePlay(playbackState: Int): Boolean = playbackState == Player.STATE_IDLE
87
+
88
+ @JvmStatic
89
+ fun needsRewindBeforePlay(playbackState: Int): Boolean = playbackState == Player.STATE_ENDED
90
+
91
+ /**
92
+ * `resetStreamOnPause`: resume a paused live stream at the live edge instead of where it paused.
93
+ * VOD streams (`isStream` HLS lectures) are not live and keep their position.
94
+ */
95
+ @JvmStatic
96
+ fun shouldJumpToLiveEdge(resetStreamOnPause: Boolean, isStream: Boolean, isLive: Boolean): Boolean =
97
+ resetStreamOnPause && isStream && isLive
98
+ }
@@ -0,0 +1,11 @@
1
+ package org.dwbn.plugins.playlist.manager
2
+
3
+ enum class RmxPlaybackState {
4
+ STOPPED,
5
+ RETRIEVING,
6
+ PREPARING,
7
+ SEEKING,
8
+ PLAYING,
9
+ PAUSED,
10
+ ERROR
11
+ }
@@ -1,262 +1,158 @@
1
1
  package org.dwbn.plugins.playlist.playlist;
2
2
 
3
- import android.app.Service;
4
3
  import android.content.Context;
5
- import android.util.Log;
6
-
7
4
  import androidx.annotation.Nullable;
8
-
9
- import com.devbrackets.android.playlistcore.api.MediaPlayerApi;
10
- import com.devbrackets.android.playlistcore.api.PlaylistItem;
11
- import com.devbrackets.android.playlistcore.components.audiofocus.AudioFocusProvider;
12
- import com.devbrackets.android.playlistcore.components.audiofocus.DefaultAudioFocusProvider;
13
- import com.devbrackets.android.playlistcore.components.image.ImageProvider;
14
- import com.devbrackets.android.playlistcore.components.mediacontrols.DefaultMediaControlsProvider;
15
- import com.devbrackets.android.playlistcore.components.mediacontrols.MediaControlsProvider;
16
- import com.devbrackets.android.playlistcore.components.mediasession.DefaultMediaSessionProvider;
17
- import com.devbrackets.android.playlistcore.components.mediasession.MediaSessionProvider;
18
- import com.devbrackets.android.playlistcore.components.playlisthandler.DefaultPlaylistHandler;
19
- import com.devbrackets.android.playlistcore.data.PlaybackState;
20
- import com.devbrackets.android.playlistcore.manager.BasePlaylistManager;
21
-
22
- import org.dwbn.plugins.playlist.RmxAudioPlayer;
5
+ import androidx.annotation.OptIn;
6
+ import androidx.media3.common.Player;
7
+ import androidx.media3.common.util.UnstableApi;
23
8
  import org.dwbn.plugins.playlist.data.AudioTrack;
24
9
  import org.dwbn.plugins.playlist.manager.PlaylistManager;
25
- import org.dwbn.plugins.playlist.notification.PlaylistNotificationProvider;
26
- import org.dwbn.plugins.playlist.service.MediaService;
27
- import org.jetbrains.annotations.NotNull;
28
-
29
-
30
- public class AudioPlaylistHandler<I extends PlaylistItem, M extends BasePlaylistManager<I>>
31
- extends DefaultPlaylistHandler<I, M> {
10
+ import org.dwbn.plugins.playlist.manager.PlaylistPlaybackPolicy;
11
+ import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge;
12
+ import org.dwbn.plugins.playlist.service.MediaNotificationPolicy;
13
+
14
+ /**
15
+ * ExoPlayer playback facade for {@link org.dwbn.plugins.playlist.PlaylistPlugin}. Story 55.4
16
+ * replaces PlaylistCore's {@code DefaultPlaylistHandler}; notification/focus polish is Story 55.5,
17
+ * full video handoff behaviour is Story 55.6.
18
+ */
19
+ @OptIn(markerClass = UnstableApi.class)
20
+ public class AudioPlaylistHandler {
21
+
22
+ private final Context context;
23
+ private final PlaylistManager playlistManager;
24
+
25
+ public AudioPlaylistHandler(Context context, PlaylistManager playlistManager) {
26
+ this.context = context.getApplicationContext();
27
+ this.playlistManager = playlistManager;
28
+ }
32
29
 
33
- private static final String TAG = "PlaylistAudioPlaylistHandler";
30
+ public boolean isPlaying() {
31
+ return playlistManager.isPlaying();
32
+ }
34
33
 
35
- AudioPlaylistHandler(
36
- Context context,
37
- Class<? extends Service> serviceClass,
38
- M playlistManager,
39
- ImageProvider<I> imageProvider,
40
- com.devbrackets.android.playlistcore.components.notification.PlaylistNotificationProvider notificationProvider,
41
- MediaSessionProvider mediaSessionProvider,
42
- MediaControlsProvider mediaControlsProvider,
43
- AudioFocusProvider<I> audioFocusProvider,
44
- @Nullable Listener<I> listener
45
- ) {
46
- super(context, serviceClass, playlistManager, imageProvider, notificationProvider,
47
- mediaSessionProvider, mediaControlsProvider, audioFocusProvider, listener);
48
- // Lmao this entire class exists for the sake of this one line
49
- // The default value is 30fps (e.g 33ms), which would overwhelm the Cordova webview with messages
50
- // Ideally we could make this configurable.
51
- getMediaProgressPoll().setProgressPollDelay(1000);
34
+ @Nullable
35
+ public Player getCurrentMediaPlayer() {
36
+ return playlistManager.getPlayer();
52
37
  }
53
38
 
54
- public void next() {
55
- if (!getPlaylistManager().isNextAvailable()) {
39
+ public void play() {
40
+ if (MediaNotificationPolicy.shouldIgnoreSessionPlay(
41
+ playlistManager.isVideoHandoffPrewarmActive(),
42
+ VideoPlayerBridge.hasActivePlayer()
43
+ )) {
44
+ playlistManager.ensureForeground();
56
45
  return;
57
46
  }
58
- getPlaylistManager().next();
59
- startItemPlayback(0, !this.isPlaying());
47
+ playlistManager.ensureServiceStarted();
48
+ playlistManager.ensureForeground();
49
+ if (MediaNotificationPolicy.shouldRequestLegacyAudioFocus()) {
50
+ // Media3 handleAudioFocus owns pause/resume; do not call AudioManager.requestAudioFocus.
51
+ }
52
+ Player player = playlistManager.getPlayer();
53
+ AudioTrack track = playlistManager.getCurrentItem();
54
+ jumpToLiveEdgeIfReset(player, playlistManager.getResetStreamOnPause(), track != null && track.isStream());
55
+ startAudible(player);
60
56
  }
61
57
 
62
- public void previous() {
63
- if (!getPlaylistManager().isPreviousAvailable()) {
64
- return;
58
+ /** Visible for JVM tests — `resetStreamOnPause` resumes live streams at the live edge. */
59
+ static void jumpToLiveEdgeIfReset(@Nullable Player player, boolean resetStreamOnPause, boolean isStream) {
60
+ if (player != null
61
+ && PlaylistPlaybackPolicy.shouldJumpToLiveEdge(resetStreamOnPause, isStream, player.isCurrentMediaItemLive())) {
62
+ player.seekToDefaultPosition();
65
63
  }
66
- getPlaylistManager().previous();
67
- startItemPlayback(0, !this.isPlaying());
68
64
  }
69
65
 
70
- /**
71
- * Request audio focus before starting ExoMedia — DefaultPlaylistHandler.play() starts the
72
- * MediaPlayer first, which fails silently after native video handoff (focus abandoned in
73
- * pauseForVideoHandoff while the video ExoPlayer still held the stream).
74
- * During Epic 45 prewarm (native video active), never request focus or start audible playback.
75
- */
76
- @Override
77
- public void play() {
78
- if (isVideoHandoffPrewarmActive()) {
79
- setupForeground();
66
+ /** Visible for JVM tests — PlaylistCore's play restarted an idle or finished player. */
67
+ static void startAudible(@Nullable Player player) {
68
+ if (player == null) {
80
69
  return;
81
70
  }
82
- getAudioFocusProvider().requestFocus();
83
- com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> mediaPlayer = getCurrentMediaPlayer();
84
- if (mediaPlayer != null) {
85
- if (mediaPlayer.isPlaying()) {
86
- mediaPlayer.pause();
87
- }
88
- mediaPlayer.play();
71
+ int state = player.getPlaybackState();
72
+ if (PlaylistPlaybackPolicy.needsRewindBeforePlay(state)) {
73
+ player.seekToDefaultPosition(player.getCurrentMediaItemIndex());
74
+ }
75
+ if (PlaylistPlaybackPolicy.needsPrepareBeforePlay(state)) {
76
+ player.prepare();
89
77
  }
90
- getMediaProgressPoll().start();
91
- setPlaybackState(PlaybackState.PLAYING);
92
- setupForeground();
78
+ player.setPlayWhenReady(true);
93
79
  }
94
80
 
95
- /**
96
- * Resume at {@code positionMs} after native video ends. Re-requests focus and starts playback.
97
- * <p>
98
- * Must {@link #play()} before {@link #seek(long)}: playlistcore {@code performSeek} sets
99
- * {@code playingBeforeSeek = isPlaying()}. After video handoff the player is paused, so
100
- * seek-then-play races with {@code onSeekComplete}, which calls {@code pause()} when
101
- * {@code playingBeforeSeek} is false — UI briefly shows playing while native audio stays silent.
102
- */
103
- public void resumePlaybackAfterVideoHandoff(long positionMs) {
104
- ((PlaylistManager) getPlaylistManager()).setVideoHandoffForegroundRetain(false);
105
- setStartPaused(false);
106
- getAudioFocusProvider().requestFocus();
107
- play();
108
- if (positionMs > 0) {
109
- seek(positionMs);
81
+ public void pause(boolean isTemporary) {
82
+ Player player = playlistManager.getPlayer();
83
+ if (player != null) {
84
+ player.setPlayWhenReady(false);
110
85
  }
111
86
  }
112
87
 
113
- /**
114
- * Release audio focus for video without tearing down the foreground service (Epic 45 / Android 17).
115
- */
116
- public void pauseForVideoHandoff() {
117
- if (isPlaying()) {
118
- com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> mediaPlayer = getCurrentMediaPlayer();
119
- if (mediaPlayer != null) {
120
- mediaPlayer.pause();
121
- }
88
+ public void seek(long positionMs) {
89
+ Player player = playlistManager.getPlayer();
90
+ if (player != null) {
91
+ player.seekTo(positionMs);
122
92
  }
123
- getMediaProgressPoll().stop();
124
- setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
125
- getAudioFocusProvider().abandonFocus();
126
93
  }
127
94
 
128
- @Override
129
- public void pause(boolean isTemporary) {
130
- if (((PlaylistManager) getPlaylistManager()).getVideoHandoffForegroundRetain()) {
131
- if (isPlaying()) {
132
- com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> mediaPlayer = getCurrentMediaPlayer();
133
- if (mediaPlayer != null) {
134
- mediaPlayer.pause();
135
- }
136
- }
137
- getMediaProgressPoll().stop();
138
- setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
139
- if (!isTemporary) {
140
- getAudioFocusProvider().abandonFocus();
141
- }
95
+ public void startItemPlayback(long positionMs, boolean startPaused) {
96
+ playlistManager.ensureServiceStarted();
97
+ playlistManager.beginPlaybackAt(positionMs, startPaused);
98
+ }
99
+
100
+ public void next() {
101
+ if (!playlistManager.isNextAvailable()) {
142
102
  return;
143
103
  }
144
- super.pause(isTemporary);
104
+ playlistManager.skipToNext();
145
105
  }
146
106
 
147
- @Override
148
- protected void relaxResources() {
149
- if (((PlaylistManager) getPlaylistManager()).getVideoHandoffForegroundRetain()) {
107
+ public void previous() {
108
+ if (!playlistManager.isPreviousAvailable()) {
150
109
  return;
151
110
  }
152
- super.relaxResources();
111
+ playlistManager.skipToPrevious();
153
112
  }
154
113
 
155
114
  /**
156
- * Android 17 AudioHardening: re-calling startForeground from background revokes WIU and mutes playback.
157
- * When prewarm already promoted MediaService, only update the notification via super when not foreground.
115
+ * Resume at {@code positionMs} after native video ends. Media3 {@code handleAudioFocus} owns
116
+ * focus. Clears retain before audible play, then play-then-seek (seek-while-paused stays silent).
158
117
  */
159
- @Override
160
- protected void setupForeground() {
161
- MediaService service = MediaService.getInstance();
162
- if (service != null && service.isRunningInForeground()) {
163
- return;
164
- }
165
- super.setupForeground();
118
+ public void resumePlaybackAfterVideoHandoff(long positionMs) {
119
+ playlistManager.setVideoHandoffForegroundRetain(false);
120
+ playlistManager.ensureServiceStarted();
121
+ playlistManager.ensureForeground();
122
+ applyAudibleResume(playlistManager.getPlayer(), positionMs);
166
123
  }
167
124
 
168
- @Override
169
- public void onPrepared(@NotNull MediaPlayerApi<I> mediaPlayer) {
170
- if (isVideoHandoffPrewarmActive()) {
171
- // Prewarm: prepare at seek position but stay silent — native video owns audio focus.
172
- super.onPrepared(mediaPlayer);
173
- com.devbrackets.android.playlistcore.api.MediaPlayerApi<I> currentPlayer = getCurrentMediaPlayer();
174
- if (currentPlayer != null && currentPlayer.isPlaying()) {
175
- currentPlayer.pause();
176
- }
177
- getMediaProgressPoll().stop();
178
- setPlaybackState(com.devbrackets.android.playlistcore.data.PlaybackState.PAUSED);
179
- getAudioFocusProvider().abandonFocus();
125
+ /** Visible for JVM tests — play then seek so audio is not left paused after video. */
126
+ static void applyAudibleResume(@Nullable Player player, long positionMs) {
127
+ if (player == null) {
180
128
  return;
181
129
  }
182
- super.onPrepared(mediaPlayer);
183
- }
184
-
185
- private boolean isVideoHandoffPrewarmActive() {
186
- return ((PlaylistManager) getPlaylistManager()).getVideoHandoffForegroundRetain();
187
- }
188
-
189
- @Override
190
- public boolean onError(@NotNull MediaPlayerApi<I> mediaPlayer) {
191
- ((PlaylistManager)getPlaylistManager()).setCurrentErrorTrack((AudioTrack) getCurrentPlaylistItem());
192
- int currentIndex = getPlaylistManager().getCurrentPosition();
193
- int currentErrorCount = getSequentialErrors();
194
-
195
- super.onError(mediaPlayer);
196
- // Do not set startPaused to false if we are at the first item.
197
- // For all other items, the user MUST have triggered playback;
198
- // for item 0, they will never have done so at this point (since the tracks
199
- // are auto-buffered when a list is loaded).
200
- // This is a bit of a guess. What happens of tracks 2,3,4 are also broken?
201
- // User has no network? The only way to be *certain* is to capture all user interaction
202
- // input points and create a global flag "somewhere" that says the user has tried to play.
203
- // Maintaining that would be a nightmare.
204
- if (currentIndex > 0 && currentErrorCount <= 3) {
205
- Log.e(TAG, "ListHandler error: setting startPaused to false");
206
- setStartPaused(false);
130
+ if (MediaNotificationPolicy.shouldPlayThenSeekOnResume()) {
131
+ player.setPlayWhenReady(true);
132
+ if (positionMs > 0) {
133
+ player.seekTo(positionMs);
134
+ }
135
+ } else {
136
+ if (positionMs > 0) {
137
+ player.seekTo(positionMs);
138
+ }
139
+ player.setPlayWhenReady(true);
207
140
  }
208
-
209
- return false;
210
- }
211
-
212
- @Override
213
- public void onSeekComplete(MediaPlayerApi<I> mediaPlayer) {
214
- Log.i(TAG, "onSeekComplete! " + mediaPlayer.getCurrentPosition());
215
- getCurrentMediaProgress().update(mediaPlayer.getCurrentPosition(), mediaPlayer.getBufferedPercent(), mediaPlayer.getDuration());
216
- super.onSeekComplete(mediaPlayer);
217
141
  }
218
142
 
219
- @Override
220
- public void onCompletion(@NotNull MediaPlayerApi<I> mediaPlayer) {
221
- ((RmxAudioPlayer)super.getPlaylistManager().getPlaybackStatusListener()).onCompletion((AudioTrack) getCurrentPlaylistItem());
222
- Log.i("AudioPlaylistHandler", "onCompletion");
223
- // This is called when a single item completes playback.
224
- // For now, the superclass does the right thing, but we may need to override.
225
- super.onCompletion(mediaPlayer);
143
+ /** Pause for video without tearing down the foreground service (Epic 45 / Story 55.6). */
144
+ public void pauseForVideoHandoff() {
145
+ pauseForVideoHandoff(playlistManager.getPlayer());
226
146
  }
227
147
 
228
-
229
- public static class Builder<I extends PlaylistItem, M extends BasePlaylistManager<I>> {
230
-
231
- Context context;
232
- Class<? extends Service> serviceClass;
233
- M playlistManager;
234
- ImageProvider<I> imageProvider;
235
-
236
- com.devbrackets.android.playlistcore.components.notification.PlaylistNotificationProvider notificationProvider = null;
237
- MediaSessionProvider mediaSessionProvider = null;
238
- MediaControlsProvider mediaControlsProvider = null;
239
- AudioFocusProvider<I> audioFocusProvider = null;
240
- Listener<I> listener;
241
- public Builder(Context context, Class<? extends Service> serviceClass,
242
- M playlistManager, ImageProvider<I> imageProvider, Listener<I> listener) {
243
- this.context = context;
244
- this.serviceClass = serviceClass;
245
- this.playlistManager = playlistManager;
246
- this.imageProvider = imageProvider;
247
- this.listener = listener;
148
+ /** Visible for JVM tests — pauses even when {@code isPlaying} is false. */
149
+ static void pauseForVideoHandoff(@Nullable Player player) {
150
+ if (player != null && MediaNotificationPolicy.shouldPauseForVideoHandoff(player.isPlaying())) {
151
+ player.setPlayWhenReady(false);
248
152
  }
153
+ }
249
154
 
250
- public AudioPlaylistHandler<I, M> build() {
251
- return new AudioPlaylistHandler<>(context,
252
- serviceClass,
253
- playlistManager,
254
- imageProvider,
255
- notificationProvider != null ? notificationProvider : new PlaylistNotificationProvider(context),
256
- mediaSessionProvider != null ? mediaSessionProvider : new DefaultMediaSessionProvider(context, serviceClass),
257
- mediaControlsProvider != null ? mediaControlsProvider : new DefaultMediaControlsProvider(context),
258
- audioFocusProvider != null ? audioFocusProvider : new DefaultAudioFocusProvider<I>(context),
259
- listener);
260
- }
155
+ public void updateMediaControls() {
156
+ playlistManager.updateForegroundNotification();
261
157
  }
262
158
  }
@@ -0,0 +1,121 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.drawable.Drawable
7
+ import android.net.Uri
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import androidx.annotation.OptIn
11
+ import androidx.media3.common.MediaMetadata
12
+ import androidx.media3.common.util.BitmapLoader
13
+ import androidx.media3.common.util.UnstableApi
14
+ import com.bumptech.glide.Glide
15
+ import com.bumptech.glide.request.target.CustomTarget
16
+ import com.bumptech.glide.request.transition.Transition
17
+ import com.google.common.util.concurrent.ListenableFuture
18
+ import com.google.common.util.concurrent.SettableFuture
19
+
20
+ /**
21
+ * Glide-backed [BitmapLoader] for Media3 notification / lock-screen artwork.
22
+ * Load failures complete the future exceptionally so Media3 keeps the small icon only.
23
+ */
24
+ @OptIn(UnstableApi::class)
25
+ class GlideBitmapLoader(context: Context) : BitmapLoader {
26
+ private val appContext = context.applicationContext
27
+ private val mainHandler = Handler(Looper.getMainLooper())
28
+
29
+ override fun supportsMimeType(mimeType: String): Boolean {
30
+ return isSupportedImageMimeType(mimeType)
31
+ }
32
+
33
+ override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
34
+ val future = SettableFuture.create<Bitmap>()
35
+ try {
36
+ val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
37
+ if (bitmap == null) {
38
+ future.setException(IllegalStateException("Failed to decode artwork bytes"))
39
+ } else {
40
+ future.set(bitmap)
41
+ }
42
+ } catch (t: Throwable) {
43
+ if (!future.isDone) {
44
+ future.setException(t)
45
+ }
46
+ }
47
+ return future
48
+ }
49
+
50
+ override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
51
+ return loadWithGlide(uri)
52
+ }
53
+
54
+ override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
55
+ if (MediaNotificationPolicy.hasNoLargeArtwork(metadata.artworkData, metadata.artworkUri?.toString())) {
56
+ return null
57
+ }
58
+ val data = metadata.artworkData
59
+ if (data != null && data.isNotEmpty()) {
60
+ return decodeBitmap(data)
61
+ }
62
+ val uri = metadata.artworkUri
63
+ if (uri != null && MediaNotificationPolicy.shouldLoadRemoteArtwork(uri.toString())) {
64
+ return loadBitmap(uri)
65
+ }
66
+ return null
67
+ }
68
+
69
+ private fun loadWithGlide(model: Any): ListenableFuture<Bitmap> {
70
+ val future = SettableFuture.create<Bitmap>()
71
+ val load = Runnable {
72
+ try {
73
+ Glide.with(appContext)
74
+ .asBitmap()
75
+ .load(model)
76
+ .into(object : CustomTarget<Bitmap>() {
77
+ override fun onResourceReady(
78
+ resource: Bitmap,
79
+ transition: Transition<in Bitmap>?
80
+ ) {
81
+ future.set(resource)
82
+ }
83
+
84
+ override fun onLoadFailed(errorDrawable: Drawable?) {
85
+ if (!future.isDone) {
86
+ future.setException(IllegalStateException("Artwork load failed"))
87
+ }
88
+ }
89
+
90
+ override fun onLoadCleared(placeholder: Drawable?) {
91
+ if (!future.isDone) {
92
+ future.setException(IllegalStateException("Artwork load cleared"))
93
+ }
94
+ }
95
+ })
96
+ } catch (t: Throwable) {
97
+ if (!future.isDone) {
98
+ future.setException(t)
99
+ }
100
+ }
101
+ }
102
+ if (Looper.myLooper() == Looper.getMainLooper()) {
103
+ load.run()
104
+ } else if (!mainHandler.post(load)) {
105
+ future.setException(IllegalStateException("Artwork load post failed"))
106
+ }
107
+ return future
108
+ }
109
+
110
+ companion object {
111
+ @JvmStatic
112
+ fun isSupportedImageMimeType(mimeType: String?): Boolean {
113
+ return !mimeType.isNullOrEmpty() && mimeType.startsWith("image/", ignoreCase = true)
114
+ }
115
+
116
+ @JvmStatic
117
+ fun shouldLoadRemoteArtwork(artworkUri: String?): Boolean {
118
+ return MediaNotificationPolicy.shouldLoadRemoteArtwork(artworkUri)
119
+ }
120
+ }
121
+ }