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,185 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import androidx.annotation.OptIn
6
+ import androidx.media3.common.ForwardingPlayer
7
+ import androidx.media3.common.Player
8
+ import androidx.media3.common.util.UnstableApi
9
+ import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge
10
+ import java.util.concurrent.CopyOnWriteArrayList
11
+
12
+ /**
13
+ * Routes the playlist MediaSession to the video ExoPlayer while handoff retain is active and video
14
+ * is attached; otherwise blocks play during retain-only prewarm (Story 55.6).
15
+ */
16
+ @OptIn(UnstableApi::class)
17
+ internal class HandoffForwardingPlayer(
18
+ player: Player,
19
+ private val retain: () -> Boolean,
20
+ private val onSkipToNext: (() -> Unit)? = null,
21
+ private val onSkipToPrevious: (() -> Unit)? = null,
22
+ private val previousAvailable: (() -> Boolean)? = null,
23
+ private val nextAvailable: (() -> Boolean)? = null
24
+ ) : ForwardingPlayer(player) {
25
+
26
+ private val sessionListeners = CopyOnWriteArrayList<Player.Listener>()
27
+ private val playbackRelay = { dispatchVideoPlaybackToSession() }
28
+
29
+ init {
30
+ VideoPlayerBridge.addPlaybackChangedListener(playbackRelay)
31
+ if (VideoPlayerBridge.hasActivePlayer()) {
32
+ playbackRelay()
33
+ }
34
+ }
35
+
36
+ override fun addListener(listener: Player.Listener) {
37
+ super.addListener(listener)
38
+ sessionListeners.addIfAbsent(listener)
39
+ if (videoPlayer() != null) {
40
+ playbackRelay()
41
+ }
42
+ }
43
+
44
+ override fun removeListener(listener: Player.Listener) {
45
+ super.removeListener(listener)
46
+ sessionListeners.remove(listener)
47
+ }
48
+
49
+ private fun videoPlayer(): Player? {
50
+ if (!retain() || !VideoPlayerBridge.hasActivePlayer()) {
51
+ return null
52
+ }
53
+ return VideoPlayerBridge.activePlayer()
54
+ }
55
+
56
+ override fun play() {
57
+ val video = videoPlayer()
58
+ if (video != null) {
59
+ video.play()
60
+ return
61
+ }
62
+ if (MediaNotificationPolicy.shouldIgnoreSessionPlay(retain(), VideoPlayerBridge.hasActivePlayer())) {
63
+ return
64
+ }
65
+ super.play()
66
+ }
67
+
68
+ override fun pause() {
69
+ videoPlayer()?.pause() ?: super.pause()
70
+ }
71
+
72
+ override fun setPlayWhenReady(playWhenReady: Boolean) {
73
+ val video = videoPlayer()
74
+ if (video != null) {
75
+ video.playWhenReady = playWhenReady
76
+ return
77
+ }
78
+ if (playWhenReady && MediaNotificationPolicy.shouldIgnoreSessionPlay(retain(), VideoPlayerBridge.hasActivePlayer())) {
79
+ return
80
+ }
81
+ super.setPlayWhenReady(playWhenReady)
82
+ }
83
+
84
+ override fun getPlayWhenReady(): Boolean = videoPlayer()?.playWhenReady ?: super.getPlayWhenReady()
85
+
86
+ override fun isPlaying(): Boolean {
87
+ val video = videoPlayer()
88
+ if (video != null) {
89
+ return video.playWhenReady && video.playbackState == Player.STATE_READY
90
+ }
91
+ return super.isPlaying()
92
+ }
93
+
94
+ override fun getPlaybackState(): Int = videoPlayer()?.playbackState ?: super.getPlaybackState()
95
+
96
+ override fun getCurrentPosition(): Long = videoPlayer()?.currentPosition ?: super.getCurrentPosition()
97
+
98
+ override fun getContentPosition(): Long = videoPlayer()?.contentPosition ?: super.getContentPosition()
99
+
100
+ override fun getDuration(): Long = videoPlayer()?.duration ?: super.getDuration()
101
+
102
+ override fun getBufferedPosition(): Long = videoPlayer()?.bufferedPosition ?: super.getBufferedPosition()
103
+
104
+ override fun seekTo(positionMs: Long) {
105
+ videoPlayer()?.seekTo(positionMs) ?: super.seekTo(positionMs)
106
+ }
107
+
108
+ override fun seekTo(windowIndex: Int, positionMs: Long) {
109
+ videoPlayer()?.seekTo(windowIndex, positionMs) ?: super.seekTo(windowIndex, positionMs)
110
+ }
111
+
112
+ override fun seekToNextMediaItem() {
113
+ if (videoPlayer() != null) {
114
+ return
115
+ }
116
+ onSkipToNext?.invoke() ?: super.seekToNextMediaItem()
117
+ }
118
+
119
+ override fun seekToPreviousMediaItem() {
120
+ if (videoPlayer() != null) {
121
+ return
122
+ }
123
+ onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem()
124
+ }
125
+
126
+ override fun getAvailableCommands(): Player.Commands {
127
+ val previous = previousAvailable
128
+ val next = nextAvailable
129
+ if (previous == null && next == null) {
130
+ return super.getAvailableCommands()
131
+ }
132
+ val retainActive = retain()
133
+ val videoAttached = VideoPlayerBridge.hasActivePlayer()
134
+ return MediaNotificationPolicy.playerCommandsForMediaNotification(
135
+ super.getAvailableCommands(),
136
+ MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
137
+ previous?.invoke() ?: false,
138
+ retainActive,
139
+ videoAttached
140
+ ),
141
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
142
+ next?.invoke() ?: false,
143
+ retainActive,
144
+ videoAttached
145
+ )
146
+ )
147
+ }
148
+
149
+ private fun dispatchVideoPlaybackToSession() {
150
+ val looper = applicationLooperOrNull()
151
+ if (looper != null && Looper.myLooper() != looper) {
152
+ Handler(looper).post { dispatchVideoPlaybackToSessionOnLooper() }
153
+ return
154
+ }
155
+ dispatchVideoPlaybackToSessionOnLooper()
156
+ }
157
+
158
+ private fun dispatchVideoPlaybackToSessionOnLooper() {
159
+ if (videoPlayer() == null) {
160
+ return
161
+ }
162
+ val ready = playWhenReady
163
+ val state = playbackState
164
+ val playing = isPlaying
165
+ val commands = try {
166
+ availableCommands
167
+ } catch (_: Throwable) {
168
+ null
169
+ }
170
+ for (listener in sessionListeners) {
171
+ listener.onPlayWhenReadyChanged(ready, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
172
+ listener.onPlaybackStateChanged(state)
173
+ listener.onIsPlayingChanged(playing)
174
+ if (commands != null) {
175
+ listener.onAvailableCommandsChanged(commands)
176
+ }
177
+ }
178
+ }
179
+
180
+ private fun applicationLooperOrNull(): Looper? = try {
181
+ applicationLooper
182
+ } catch (_: Throwable) {
183
+ null
184
+ }
185
+ }
@@ -0,0 +1,253 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import android.content.Intent
4
+ import android.os.Build
5
+ import androidx.annotation.OptIn
6
+ import androidx.media3.common.C
7
+ import androidx.media3.common.Player
8
+ import androidx.media3.common.util.UnstableApi
9
+ import androidx.media3.session.DefaultMediaNotificationProvider
10
+
11
+ /**
12
+ * Pure notification / focus / video-handoff decisions for Stories 55.5–55.6.
13
+ * [MediaService], [org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler] and
14
+ * [org.dwbn.plugins.playlist.RmxAudioPlayer] call these so JVM tests can cover the I/O matrix
15
+ * without a device.
16
+ */
17
+ @OptIn(UnstableApi::class)
18
+ object MediaNotificationPolicy {
19
+ const val HANDLE_AUDIO_FOCUS = true
20
+
21
+ /** FGS start blocked by notification permission or related security policy. */
22
+ @JvmStatic
23
+ fun isForegroundStartSecurityBlocked(error: Throwable): Boolean = error is SecurityException
24
+
25
+ @JvmStatic
26
+ fun isForegroundStartNotAllowedFromBackground(error: Throwable): Boolean =
27
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
28
+ error is android.app.ForegroundServiceStartNotAllowedException
29
+
30
+ /** Distinct from the video plugin session (`org.dwbn.video`); Media3 forbids two empty IDs. */
31
+ const val MEDIA_SESSION_ID = "org.dwbn.playlist"
32
+
33
+ /**
34
+ * [MediaService.promoteToForeground] must not call [android.app.Service.startForeground]
35
+ * (Android 12+ forbids restarting FGS from the background after video).
36
+ * FGS starts at the beginning of [MediaService.onCreate] so the startForegroundService
37
+ * timeout cannot fire while ExoPlayer / MediaSession are built. Media3 then replaces that
38
+ * notification; [MediaService.onStartCommand] must not post the title-only placeholder again.
39
+ */
40
+ const val ALLOW_START_FOREGROUND_ON_PROMOTE = false
41
+
42
+ /** Title-only FGS placeholder is only allowed before the session exists (onCreate start). */
43
+ const val ALLOW_TITLE_ONLY_NOTIFICATION_AFTER_SESSION = false
44
+
45
+ /** Show Media3's session notification before playback is ready so FGS starts within the timeout. */
46
+ const val SHOW_NOTIFICATION_WHEN_IDLE =
47
+ androidx.media3.session.MediaSessionService.SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS
48
+
49
+ val mediaNotificationProviderClass: Class<out DefaultMediaNotificationProvider>
50
+ get() = DefaultMediaNotificationProvider::class.java
51
+
52
+ @JvmStatic
53
+ fun shouldStartForegroundOnPromote(): Boolean = ALLOW_START_FOREGROUND_ON_PROMOTE
54
+
55
+ /** A second [android.content.Context.startForegroundService] while onCreate is still running trips the FGS timeout. */
56
+ @JvmStatic
57
+ fun shouldStartForegroundService(serviceAlreadyCreated: Boolean): Boolean = !serviceAlreadyCreated
58
+
59
+ @JvmStatic
60
+ fun shouldRequestLegacyAudioFocus(): Boolean = false
61
+
62
+ @JvmStatic
63
+ fun shouldLoadRemoteArtwork(artworkUri: String?): Boolean = !artworkUri.isNullOrEmpty()
64
+
65
+ @JvmStatic
66
+ fun hasNoLargeArtwork(artworkData: ByteArray?, artworkUri: String?): Boolean =
67
+ (artworkData == null || artworkData.isEmpty()) && !shouldLoadRemoteArtwork(artworkUri)
68
+
69
+ @JvmStatic
70
+ fun shouldSkipEndForeground(videoHandoffForegroundRetain: Boolean): Boolean =
71
+ videoHandoffForegroundRetain
72
+
73
+ /**
74
+ * Pause even when buffering / [androidx.media3.common.Player.isPlaying] is false so Media3
75
+ * [handleAudioFocus] abandons. Teaching-sequence video may call prepare while not playing.
76
+ */
77
+ @JvmStatic
78
+ fun shouldPauseForVideoHandoff(isPlaying: Boolean): Boolean = true
79
+
80
+ /** Teaching-sequence path has no prewarm — retain must start on prepare. */
81
+ @JvmStatic
82
+ fun shouldRetainForegroundOnPrepare(): Boolean = true
83
+
84
+ /**
85
+ * Media3 caps [androidx.media3.session.MediaSessionService.setForegroundServiceTimeoutMs]
86
+ * at 10 minutes, so [MediaService.onUpdateNotificationAsync] must force
87
+ * `startInForegroundRequired=true` while retain is set.
88
+ */
89
+ @JvmStatic
90
+ fun shouldForceNotificationForeground(videoHandoffForegroundRetain: Boolean): Boolean =
91
+ videoHandoffForegroundRetain
92
+
93
+ @JvmStatic
94
+ fun startInForegroundRequired(
95
+ videoHandoffForegroundRetain: Boolean,
96
+ media3Requested: Boolean
97
+ ): Boolean = media3Requested || shouldForceNotificationForeground(videoHandoffForegroundRetain)
98
+
99
+ /** MediaSession / notification play must not start audible audio while retain is set and video is absent. */
100
+ @JvmStatic
101
+ fun shouldIgnoreSessionPlay(videoHandoffForegroundRetain: Boolean): Boolean =
102
+ shouldIgnoreSessionPlay(videoHandoffForegroundRetain, videoHandoffPlayerAttached = false)
103
+
104
+ @JvmStatic
105
+ fun shouldIgnoreSessionPlay(
106
+ videoHandoffForegroundRetain: Boolean,
107
+ videoHandoffPlayerAttached: Boolean
108
+ ): Boolean = videoHandoffForegroundRetain && !videoHandoffPlayerAttached
109
+
110
+ @JvmStatic
111
+ fun shouldDelegateNotificationToVideo(
112
+ videoHandoffForegroundRetain: Boolean,
113
+ videoHandoffPlayerAttached: Boolean
114
+ ): Boolean = videoHandoffForegroundRetain && videoHandoffPlayerAttached
115
+
116
+ @JvmStatic
117
+ fun mediaNotificationSkipPreviousEnabled(
118
+ previousAvailable: Boolean,
119
+ videoHandoffForegroundRetain: Boolean,
120
+ videoHandoffPlayerAttached: Boolean
121
+ ): Boolean {
122
+ if (shouldDelegateNotificationToVideo(videoHandoffForegroundRetain, videoHandoffPlayerAttached)) {
123
+ return false
124
+ }
125
+ return mediaNotificationSkipPreviousEnabled(previousAvailable)
126
+ }
127
+
128
+ @JvmStatic
129
+ fun mediaNotificationSkipNextEnabled(
130
+ nextAvailable: Boolean,
131
+ videoHandoffForegroundRetain: Boolean,
132
+ videoHandoffPlayerAttached: Boolean
133
+ ): Boolean {
134
+ if (shouldDelegateNotificationToVideo(videoHandoffForegroundRetain, videoHandoffPlayerAttached)) {
135
+ return false
136
+ }
137
+ return mediaNotificationSkipNextEnabled(nextAvailable)
138
+ }
139
+
140
+ /** Android audible resume stays play then seek (not seek then play). */
141
+ @JvmStatic
142
+ fun shouldPlayThenSeekOnResume(): Boolean = true
143
+
144
+ /**
145
+ * `{ resumed: true }` only for in-place audible resume while FGS is already up.
146
+ * Prewarm, paused exit, and missing FGS all return false so JS may `playTrackById`.
147
+ */
148
+ @JvmStatic
149
+ fun shouldReportInPlaceResumed(
150
+ prewarm: Boolean,
151
+ play: Boolean,
152
+ retain: Boolean,
153
+ serviceInForeground: Boolean
154
+ ): Boolean = !prewarm && play && retain && serviceInForeground
155
+
156
+ /** [org.dwbn.plugins.playlist.RmxAudioPlayer.getLastKnownPositionSec] is the handoff snapshot. */
157
+ @JvmStatic
158
+ fun lastKnownPositionSec(handoffPositionSec: Float): Float = handoffPositionSec
159
+
160
+ /**
161
+ * Failed in-place resume must not last-resort [org.dwbn.plugins.playlist.manager.PlaylistManager.beginPlayback]
162
+ * (Android 12+ forbids starting FGS from the background after video). JS may play/seek.
163
+ */
164
+ @JvmStatic
165
+ fun shouldBeginPlaybackWhenInPlaceUnavailable(): Boolean = false
166
+
167
+ /** Teaching-sequence retain starts on prepare — [org.dwbn.plugins.playlist.RmxAudioPlayer] must call this. */
168
+ @JvmStatic
169
+ fun applyForegroundRetainOnPrepare(setRetain: Runnable) {
170
+ if (shouldRetainForegroundOnPrepare()) {
171
+ setRetain.run()
172
+ }
173
+ }
174
+
175
+ /** Miss-path audible resume must not [org.dwbn.plugins.playlist.manager.PlaylistManager.beginPlayback]. */
176
+ @JvmStatic
177
+ fun applyBeginPlaybackWhenInPlaceUnavailable(beginPlayback: Runnable) {
178
+ if (shouldBeginPlaybackWhenInPlaceUnavailable()) {
179
+ beginPlayback.run()
180
+ }
181
+ }
182
+
183
+ /**
184
+ * [org.dwbn.plugins.playlist.manager.PlaylistManager] player listener must call this when
185
+ * [androidx.media3.common.Player.Listener.onPlayWhenReadyChanged] fires.
186
+ */
187
+ @JvmStatic
188
+ fun applyRetainPlayWhenReadyGuard(
189
+ playWhenReady: Boolean,
190
+ videoHandoffForegroundRetain: Boolean,
191
+ videoHandoffPlayerAttached: Boolean,
192
+ pausePlayer: Runnable
193
+ ) {
194
+ if (playWhenReady &&
195
+ shouldIgnoreSessionPlay(videoHandoffForegroundRetain, videoHandoffPlayerAttached)
196
+ ) {
197
+ pausePlayer.run()
198
+ }
199
+ }
200
+
201
+ @JvmStatic
202
+ fun sessionLaunchIntentFlags(): Int =
203
+ Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP
204
+
205
+ @JvmStatic
206
+ fun shouldOverwriteMedia3NotificationOnStartCommand(): Boolean = false
207
+
208
+ @JvmStatic
209
+ fun shouldAllowTitleOnlyNotificationAfterSession(): Boolean =
210
+ ALLOW_TITLE_ONLY_NOTIFICATION_AFTER_SESSION
211
+
212
+ /** CPU + Wi-Fi lock while playing, as PlaylistCore's WifiLock and ExoMedia's partial wake lock did. */
213
+ const val WAKE_MODE = C.WAKE_MODE_NETWORK
214
+
215
+ /** `setOptions({ options: { icon } })` names an app drawable; fall back to the platform play glyph. */
216
+ @JvmStatic
217
+ fun smallIconRes(resolvedIconRes: Int): Int =
218
+ if (resolvedIconRes != 0) resolvedIconRes else android.R.drawable.ic_media_play
219
+
220
+ /** Mirrors [org.dwbn.plugins.playlist.manager.PlaylistManager.isPreviousAvailable] for JVM tests. */
221
+ @JvmStatic
222
+ fun mediaNotificationSkipPreviousEnabled(previousAvailable: Boolean): Boolean = previousAvailable
223
+
224
+ /** Mirrors [org.dwbn.plugins.playlist.manager.PlaylistManager.isNextAvailable] for JVM tests. */
225
+ @JvmStatic
226
+ fun mediaNotificationSkipNextEnabled(nextAvailable: Boolean): Boolean = nextAvailable
227
+
228
+ /**
229
+ * Starts from Media3 default / wrapped player commands so metadata and play-pause stay
230
+ * available. Skip is added or removed from that set — never replace it with a tiny list.
231
+ */
232
+ @JvmStatic
233
+ fun playerCommandsForMediaNotification(
234
+ base: Player.Commands,
235
+ previousAvailable: Boolean,
236
+ nextAvailable: Boolean
237
+ ): Player.Commands {
238
+ val builder = base.buildUpon().add(Player.COMMAND_PLAY_PAUSE)
239
+ if (mediaNotificationSkipPreviousEnabled(previousAvailable)) {
240
+ builder.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
241
+ } else {
242
+ builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
243
+ builder.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
244
+ }
245
+ if (mediaNotificationSkipNextEnabled(nextAvailable)) {
246
+ builder.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
247
+ } else {
248
+ builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
249
+ builder.remove(Player.COMMAND_SEEK_TO_NEXT)
250
+ }
251
+ return builder.build()
252
+ }
253
+ }