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,282 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import androidx.media3.common.Player
4
+ import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge
5
+ import org.junit.After
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+ import java.lang.reflect.Proxy
11
+
12
+ class HandoffForwardingPlayerTest {
13
+
14
+ @After
15
+ fun tearDown() {
16
+ VideoPlayerBridge.clearForTests()
17
+ }
18
+
19
+ @Test
20
+ fun retain_playAndSetPlayWhenReadyAreNoOps() {
21
+ val recording = RecordingPlayer()
22
+ val forwarding = HandoffForwardingPlayer(recording.proxy, retain = { true })
23
+
24
+ forwarding.play()
25
+ forwarding.setPlayWhenReady(true)
26
+
27
+ assertEquals(0, recording.playCount)
28
+ assertTrue(recording.setPlayWhenReadyCalls.isEmpty())
29
+ }
30
+
31
+ @Test
32
+ fun afterRetainCleared_playReachesPlayer() {
33
+ var retain = true
34
+ val recording = RecordingPlayer()
35
+ val forwarding = HandoffForwardingPlayer(recording.proxy, retain = { retain })
36
+
37
+ forwarding.setPlayWhenReady(true)
38
+ assertTrue(recording.setPlayWhenReadyCalls.isEmpty())
39
+
40
+ retain = false
41
+ forwarding.setPlayWhenReady(true)
42
+
43
+ assertEquals(listOf(true), recording.setPlayWhenReadyCalls)
44
+ }
45
+
46
+ @Test
47
+ fun seekToNextMediaItem_delegatesToCallback() {
48
+ var skipNextCount = 0
49
+ val recording = RecordingPlayer()
50
+ val forwarding = HandoffForwardingPlayer(
51
+ recording.proxy,
52
+ retain = { false },
53
+ onSkipToNext = { skipNextCount++ }
54
+ )
55
+
56
+ forwarding.seekToNextMediaItem()
57
+
58
+ assertEquals(1, skipNextCount)
59
+ assertEquals(0, recording.seekToNextCount)
60
+ }
61
+
62
+ @Test
63
+ fun seekToPreviousMediaItem_delegatesToCallback() {
64
+ var skipPreviousCount = 0
65
+ val recording = RecordingPlayer()
66
+ val forwarding = HandoffForwardingPlayer(
67
+ recording.proxy,
68
+ retain = { false },
69
+ onSkipToPrevious = { skipPreviousCount++ }
70
+ )
71
+
72
+ forwarding.seekToPreviousMediaItem()
73
+
74
+ assertEquals(1, skipPreviousCount)
75
+ assertEquals(0, recording.seekToPreviousCount)
76
+ }
77
+
78
+ @Test
79
+ fun retainWithVideoAttached_playReachesVideoPlayer() {
80
+ val audio = RecordingPlayer()
81
+ val video = RecordingPlayer()
82
+ VideoPlayerBridge.attach(video.proxy)
83
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
84
+
85
+ forwarding.play()
86
+
87
+ assertEquals(0, audio.playCount)
88
+ assertEquals(1, video.playCount)
89
+ }
90
+
91
+ @Test
92
+ fun retainWithVideoAttached_isPlayingReflectsVideo() {
93
+ val audio = RecordingPlayer()
94
+ val video = RecordingPlayer(playWhenReady = true, playbackState = Player.STATE_READY)
95
+ VideoPlayerBridge.attach(video.proxy)
96
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
97
+
98
+ assertTrue(forwarding.isPlaying())
99
+ }
100
+
101
+ @Test
102
+ fun retainWithVideoAttached_notPlayingWhileBufferingEvenIfPlayWhenReady() {
103
+ val audio = RecordingPlayer()
104
+ val video = RecordingPlayer(playWhenReady = true, playbackState = Player.STATE_BUFFERING)
105
+ VideoPlayerBridge.attach(video.proxy)
106
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
107
+
108
+ assertFalse(forwarding.isPlaying())
109
+ }
110
+
111
+ @Test
112
+ fun retainWithVideoAttached_pauseAndSeekHitVideo() {
113
+ val audio = RecordingPlayer()
114
+ val video = RecordingPlayer()
115
+ VideoPlayerBridge.attach(video.proxy)
116
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
117
+
118
+ forwarding.pause()
119
+ forwarding.setPlayWhenReady(false)
120
+ forwarding.seekTo(99_000L)
121
+
122
+ assertEquals(1, video.pauseCount)
123
+ assertEquals(listOf(false), video.setPlayWhenReadyCalls)
124
+ assertEquals(listOf(99_000L), video.seekToPositionMsCalls)
125
+ assertEquals(0, audio.pauseCount)
126
+ }
127
+
128
+ @Test
129
+ fun retainWithVideoAttached_positionAndDurationFromVideo() {
130
+ val audio = RecordingPlayer(currentPosition = 1L, duration = 2L, contentPosition = 3L)
131
+ val video = RecordingPlayer(currentPosition = 50L, duration = 100L, contentPosition = 55L)
132
+ VideoPlayerBridge.attach(video.proxy)
133
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
134
+
135
+ assertEquals(50L, forwarding.getCurrentPosition())
136
+ assertEquals(100L, forwarding.getDuration())
137
+ assertEquals(55L, forwarding.getContentPosition())
138
+ }
139
+
140
+ @Test
141
+ fun retainWithVideoAttached_skipMediaItemsAreNoOps() {
142
+ var skipNextCount = 0
143
+ var skipPreviousCount = 0
144
+ val audio = RecordingPlayer()
145
+ val video = RecordingPlayer()
146
+ VideoPlayerBridge.attach(video.proxy)
147
+ val forwarding = HandoffForwardingPlayer(
148
+ audio.proxy,
149
+ retain = { true },
150
+ onSkipToNext = { skipNextCount++ },
151
+ onSkipToPrevious = { skipPreviousCount++ }
152
+ )
153
+
154
+ forwarding.seekToNextMediaItem()
155
+ forwarding.seekToPreviousMediaItem()
156
+
157
+ assertEquals(0, skipNextCount)
158
+ assertEquals(0, skipPreviousCount)
159
+ assertEquals(0, video.seekToNextCount)
160
+ }
161
+
162
+ @Test
163
+ fun afterVideoDetach_skipCallbacksWorkAgainWhileRetainStillOn() {
164
+ var skipNextCount = 0
165
+ val audio = RecordingPlayer()
166
+ val video = RecordingPlayer()
167
+ VideoPlayerBridge.attach(video.proxy)
168
+ val forwarding = HandoffForwardingPlayer(
169
+ audio.proxy,
170
+ retain = { true },
171
+ onSkipToNext = { skipNextCount++ }
172
+ )
173
+
174
+ forwarding.seekToNextMediaItem()
175
+ assertEquals(0, skipNextCount)
176
+
177
+ VideoPlayerBridge.detach(video.proxy)
178
+ forwarding.seekToNextMediaItem()
179
+ assertEquals(1, skipNextCount)
180
+ }
181
+
182
+ @Test
183
+ fun afterVideoDetach_playStillIgnoredWhileRetainOn() {
184
+ val audio = RecordingPlayer()
185
+ val video = RecordingPlayer()
186
+ VideoPlayerBridge.attach(video.proxy)
187
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
188
+
189
+ VideoPlayerBridge.detach(video.proxy)
190
+ forwarding.play()
191
+
192
+ assertEquals(0, audio.playCount)
193
+ assertEquals(0, video.playCount)
194
+ }
195
+
196
+ @Test
197
+ fun retainWithVideoAttached_notifiesSessionWhenVideoAttaches() {
198
+ val audio = RecordingPlayer()
199
+ val video = RecordingPlayer(playWhenReady = true, playbackState = Player.STATE_READY)
200
+ val forwarding = HandoffForwardingPlayer(audio.proxy, retain = { true })
201
+ var playingUpdates = 0
202
+ forwarding.addListener(object : Player.Listener {
203
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
204
+ if (isPlaying) {
205
+ playingUpdates++
206
+ }
207
+ }
208
+ })
209
+
210
+ VideoPlayerBridge.attach(video.proxy)
211
+
212
+ assertTrue(playingUpdates > 0)
213
+ }
214
+
215
+ private class RecordingPlayer(
216
+ private var playWhenReady: Boolean = false,
217
+ private val playbackState: Int = Player.STATE_IDLE,
218
+ private val isPlayingValue: Boolean = false,
219
+ private val currentPosition: Long = 0L,
220
+ private val duration: Long = 0L,
221
+ private val contentPosition: Long = 0L
222
+ ) {
223
+ var playCount = 0
224
+ var pauseCount = 0
225
+ var seekToNextCount = 0
226
+ var seekToPreviousCount = 0
227
+ val seekToPositionMsCalls = mutableListOf<Long>()
228
+ val setPlayWhenReadyCalls = mutableListOf<Boolean>()
229
+ val proxy: Player = Proxy.newProxyInstance(
230
+ Player::class.java.classLoader,
231
+ arrayOf(Player::class.java)
232
+ ) { _, method, args ->
233
+ when (method.name) {
234
+ "play" -> {
235
+ playCount++
236
+ null
237
+ }
238
+ "pause" -> {
239
+ pauseCount++
240
+ null
241
+ }
242
+ "setPlayWhenReady" -> {
243
+ val ready = args[0] as Boolean
244
+ playWhenReady = ready
245
+ setPlayWhenReadyCalls.add(ready)
246
+ null
247
+ }
248
+ "seekTo" -> when (args?.size) {
249
+ 1 -> {
250
+ seekToPositionMsCalls.add(args[0] as Long)
251
+ null
252
+ }
253
+ else -> defaultValue(method.returnType)
254
+ }
255
+ "seekToNextMediaItem" -> {
256
+ seekToNextCount++
257
+ null
258
+ }
259
+ "seekToPreviousMediaItem" -> {
260
+ seekToPreviousCount++
261
+ null
262
+ }
263
+ "isPlaying", "getIsPlaying" -> isPlayingValue
264
+ "isPlayWhenReady", "getPlayWhenReady" -> playWhenReady
265
+ "getPlaybackState" -> playbackState
266
+ "getCurrentPosition" -> currentPosition
267
+ "getDuration" -> duration
268
+ "getContentPosition" -> contentPosition
269
+ else -> defaultValue(method.returnType)
270
+ }
271
+ } as Player
272
+
273
+ private fun defaultValue(returnType: Class<*>): Any? = when (returnType) {
274
+ java.lang.Boolean.TYPE, java.lang.Boolean::class.java -> false
275
+ java.lang.Integer.TYPE, java.lang.Integer::class.java -> 0
276
+ java.lang.Long.TYPE, java.lang.Long::class.java -> 0L
277
+ java.lang.Float.TYPE, java.lang.Float::class.java -> 0f
278
+ java.lang.Double.TYPE, java.lang.Double::class.java -> 0.0
279
+ else -> null
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,285 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import android.content.Intent
4
+ import androidx.media3.session.DefaultMediaNotificationProvider
5
+ import androidx.media3.session.MediaSessionService
6
+ import org.dwbn.plugins.playlist.manager.PlaylistManager
7
+ import org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Assert.assertFalse
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Test
12
+
13
+ class MediaNotificationPolicyTest {
14
+
15
+ @Test
16
+ fun backgroundPlay_usesDefaultMediaNotificationProviderOnMediaSessionService() {
17
+ assertEquals(
18
+ DefaultMediaNotificationProvider::class.java,
19
+ MediaNotificationPolicy.mediaNotificationProviderClass
20
+ )
21
+ assertTrue(MediaSessionService::class.java.isAssignableFrom(MediaService::class.java))
22
+ assertFalse(MediaNotificationPolicy.shouldStartForegroundOnPromote())
23
+ assertFalse(MediaNotificationPolicy.ALLOW_START_FOREGROUND_ON_PROMOTE)
24
+ assertEquals(
25
+ MediaSessionService.SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS,
26
+ MediaNotificationPolicy.SHOW_NOTIFICATION_WHEN_IDLE
27
+ )
28
+ }
29
+
30
+ @Test
31
+ fun artworkPresent_loadsRemoteArtwork() {
32
+ val uri = "https://example.com/art.jpg"
33
+ assertTrue(MediaNotificationPolicy.shouldLoadRemoteArtwork(uri))
34
+ assertFalse(MediaNotificationPolicy.hasNoLargeArtwork(null, uri))
35
+ }
36
+
37
+ @Test
38
+ fun artworkMissing_hasNoLargeArt() {
39
+ assertTrue(MediaNotificationPolicy.hasNoLargeArtwork(null, null))
40
+ assertTrue(MediaNotificationPolicy.hasNoLargeArtwork(null, ""))
41
+ assertTrue(MediaNotificationPolicy.hasNoLargeArtwork(ByteArray(0), null))
42
+ assertFalse(MediaNotificationPolicy.hasNoLargeArtwork(ByteArray(0), "https://example.com/art.jpg"))
43
+ assertFalse(MediaNotificationPolicy.shouldLoadRemoteArtwork(null))
44
+ assertFalse(MediaNotificationPolicy.shouldLoadRemoteArtwork(""))
45
+ }
46
+
47
+ @Test
48
+ fun focusLoss_media3HandlesFocusWithoutAudioManagerApi() {
49
+ assertTrue(MediaNotificationPolicy.HANDLE_AUDIO_FOCUS)
50
+ assertFalse(MediaNotificationPolicy.shouldRequestLegacyAudioFocus())
51
+ val managerMethods = PlaylistManager::class.java.methods.map { it.name }
52
+ assertFalse(managerMethods.contains("requestAudioFocus"))
53
+ assertFalse(managerMethods.contains("abandonAudioFocus"))
54
+ val handlerMethods = AudioPlaylistHandler::class.java.methods.map { it.name }
55
+ assertFalse(handlerMethods.contains("requestAudioFocus"))
56
+ assertFalse(handlerMethods.contains("abandonAudioFocus"))
57
+ }
58
+
59
+ @Test
60
+ fun notificationTap_usesHostLaunchReorderFlags() {
61
+ val flags = MediaNotificationPolicy.sessionLaunchIntentFlags()
62
+ assertEquals(
63
+ Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP,
64
+ flags
65
+ )
66
+ }
67
+
68
+ @Test
69
+ fun handoffRetain_skipsEndForeground() {
70
+ assertTrue(MediaNotificationPolicy.shouldSkipEndForeground(true))
71
+ assertFalse(MediaNotificationPolicy.shouldSkipEndForeground(false))
72
+ }
73
+
74
+ @Test
75
+ fun prepareForVideoHandoff_pausesRegardlessOfIsPlaying() {
76
+ assertTrue(MediaNotificationPolicy.shouldPauseForVideoHandoff(true))
77
+ assertTrue(MediaNotificationPolicy.shouldPauseForVideoHandoff(false))
78
+ }
79
+
80
+ @Test
81
+ fun foregroundStartFailure_securityExceptionIsRecoverable() {
82
+ assertTrue(MediaNotificationPolicy.isForegroundStartSecurityBlocked(SecurityException("denied")))
83
+ assertFalse(MediaNotificationPolicy.isForegroundStartSecurityBlocked(IllegalStateException()))
84
+ }
85
+
86
+ @Test
87
+ fun prepareForVideoHandoff_retainsForeground() {
88
+ val retainCalls = mutableListOf<Boolean>()
89
+ MediaNotificationPolicy.applyForegroundRetainOnPrepare { retainCalls.add(true) }
90
+ assertEquals(listOf(true), retainCalls)
91
+ }
92
+
93
+ @Test
94
+ fun retain_forcesNotificationForegroundPastMedia3Timeout() {
95
+ assertTrue(MediaNotificationPolicy.shouldForceNotificationForeground(true))
96
+ assertFalse(MediaNotificationPolicy.shouldForceNotificationForeground(false))
97
+ assertTrue(
98
+ MediaNotificationPolicy.startInForegroundRequired(
99
+ videoHandoffForegroundRetain = true,
100
+ media3Requested = false
101
+ )
102
+ )
103
+ assertTrue(
104
+ MediaNotificationPolicy.startInForegroundRequired(
105
+ videoHandoffForegroundRetain = true,
106
+ media3Requested = true
107
+ )
108
+ )
109
+ assertFalse(
110
+ MediaNotificationPolicy.startInForegroundRequired(
111
+ videoHandoffForegroundRetain = false,
112
+ media3Requested = false
113
+ )
114
+ )
115
+ assertTrue(
116
+ MediaNotificationPolicy.startInForegroundRequired(
117
+ videoHandoffForegroundRetain = false,
118
+ media3Requested = true
119
+ )
120
+ )
121
+ assertTrue(MediaService.resolveHandoffNotificationForeground(true, false))
122
+ assertFalse(MediaService.resolveHandoffNotificationForeground(false, false))
123
+ }
124
+
125
+ @Test
126
+ fun retain_playWhenReadyTrue_forcesPause() {
127
+ val pauses = mutableListOf<Boolean>()
128
+ MediaNotificationPolicy.applyRetainPlayWhenReadyGuard(
129
+ playWhenReady = true,
130
+ videoHandoffForegroundRetain = true,
131
+ videoHandoffPlayerAttached = false
132
+ ) { pauses.add(true) }
133
+ assertEquals(listOf(true), pauses)
134
+ }
135
+
136
+ @Test
137
+ fun retain_playWhenReadyFalse_doesNotForcePause() {
138
+ val pauses = mutableListOf<Boolean>()
139
+ MediaNotificationPolicy.applyRetainPlayWhenReadyGuard(
140
+ playWhenReady = false,
141
+ videoHandoffForegroundRetain = true,
142
+ videoHandoffPlayerAttached = false
143
+ ) { pauses.add(true) }
144
+ assertTrue(pauses.isEmpty())
145
+ }
146
+
147
+ @Test
148
+ fun retainWithVideoAttached_playWhenReadyTrue_doesNotForcePause() {
149
+ val pauses = mutableListOf<Boolean>()
150
+ MediaNotificationPolicy.applyRetainPlayWhenReadyGuard(
151
+ playWhenReady = true,
152
+ videoHandoffForegroundRetain = true,
153
+ videoHandoffPlayerAttached = true
154
+ ) { pauses.add(true) }
155
+ assertTrue(pauses.isEmpty())
156
+ }
157
+
158
+ @Test
159
+ fun retain_ignoresSessionPlay() {
160
+ assertTrue(MediaNotificationPolicy.shouldIgnoreSessionPlay(true))
161
+ assertFalse(MediaNotificationPolicy.shouldIgnoreSessionPlay(false))
162
+ }
163
+
164
+ @Test
165
+ fun audibleResume_playsThenSeeks() {
166
+ assertTrue(MediaNotificationPolicy.shouldPlayThenSeekOnResume())
167
+ }
168
+
169
+ @Test
170
+ fun lastKnownPosition_isHandoffSnapshot() {
171
+ assertEquals(42.5f, MediaNotificationPolicy.lastKnownPositionSec(42.5f), 0f)
172
+ assertEquals(0f, MediaNotificationPolicy.lastKnownPositionSec(0f), 0f)
173
+ }
174
+
175
+ @Test
176
+ fun resume_reportsInPlaceOnlyWhenForegroundUp() {
177
+ assertTrue(
178
+ MediaNotificationPolicy.shouldReportInPlaceResumed(
179
+ prewarm = false,
180
+ play = true,
181
+ retain = true,
182
+ serviceInForeground = true
183
+ )
184
+ )
185
+ assertFalse(
186
+ MediaNotificationPolicy.shouldReportInPlaceResumed(
187
+ prewarm = true,
188
+ play = true,
189
+ retain = true,
190
+ serviceInForeground = true
191
+ )
192
+ )
193
+ assertFalse(
194
+ MediaNotificationPolicy.shouldReportInPlaceResumed(
195
+ prewarm = false,
196
+ play = true,
197
+ retain = true,
198
+ serviceInForeground = false
199
+ )
200
+ )
201
+ assertFalse(
202
+ MediaNotificationPolicy.shouldReportInPlaceResumed(
203
+ prewarm = false,
204
+ play = false,
205
+ retain = true,
206
+ serviceInForeground = true
207
+ )
208
+ )
209
+ }
210
+
211
+ @Test
212
+ fun failedInPlaceResume_doesNotBeginPlayback() {
213
+ val beginPlaybackCalls = mutableListOf<String>()
214
+ MediaNotificationPolicy.applyBeginPlaybackWhenInPlaceUnavailable {
215
+ beginPlaybackCalls.add("beginPlayback")
216
+ }
217
+ assertTrue(beginPlaybackCalls.isEmpty())
218
+ assertFalse(MediaNotificationPolicy.shouldBeginPlaybackWhenInPlaceUnavailable())
219
+ }
220
+
221
+ @Test
222
+ fun pausedVideoExit_doesNotClaimInPlaceResume() {
223
+ assertFalse(
224
+ MediaNotificationPolicy.shouldReportInPlaceResumed(
225
+ prewarm = false,
226
+ play = false,
227
+ retain = true,
228
+ serviceInForeground = true
229
+ )
230
+ )
231
+ assertFalse(MediaNotificationPolicy.shouldBeginPlaybackWhenInPlaceUnavailable())
232
+ }
233
+
234
+ @Test
235
+ fun retainMustClearBeforeAudiblePlay() {
236
+ assertTrue(MediaNotificationPolicy.shouldIgnoreSessionPlay(true))
237
+ assertFalse(MediaNotificationPolicy.shouldIgnoreSessionPlay(false))
238
+ }
239
+
240
+ @Test
241
+ fun mediaSessionId_isDistinctFromEmptyDefault() {
242
+ assertEquals("org.dwbn.playlist", MediaNotificationPolicy.MEDIA_SESSION_ID)
243
+ assertFalse(MediaNotificationPolicy.MEDIA_SESSION_ID.isEmpty())
244
+ }
245
+
246
+ @Test
247
+ fun ensureService_skipsWhenAlreadyCreated() {
248
+ assertFalse(MediaNotificationPolicy.shouldStartForegroundService(true))
249
+ assertTrue(MediaNotificationPolicy.shouldStartForegroundService(false))
250
+ }
251
+
252
+ @Test
253
+ fun mediaNotificationSkipFlags_followPlaylistAvailability() {
254
+ assertFalse(MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(false))
255
+ assertTrue(MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(true))
256
+ assertFalse(MediaNotificationPolicy.mediaNotificationSkipNextEnabled(false))
257
+ assertTrue(MediaNotificationPolicy.mediaNotificationSkipNextEnabled(true))
258
+ }
259
+
260
+ @Test
261
+ fun onStartCommand_doesNotOverwriteMedia3Notification() {
262
+ assertFalse(MediaNotificationPolicy.shouldOverwriteMedia3NotificationOnStartCommand())
263
+ assertFalse(MediaNotificationPolicy.shouldAllowTitleOnlyNotificationAfterSession())
264
+ val methodNames = MediaService::class.java.declaredMethods.map { it.name }
265
+ assertTrue(methodNames.contains("startForegroundImmediately"))
266
+ assertTrue(methodNames.contains("startForegroundWithMedia3Notification"))
267
+ assertTrue(methodNames.contains("onUpdateNotificationAsync"))
268
+ assertTrue(methodNames.contains("onStartCommand"))
269
+ }
270
+
271
+ @Test
272
+ fun playback_holdsNetworkWakeLockLikePlaylistCore() {
273
+ assertEquals(androidx.media3.common.C.WAKE_MODE_NETWORK, MediaNotificationPolicy.WAKE_MODE)
274
+ }
275
+
276
+ @Test
277
+ fun smallIcon_usesAppDrawableFromOptions() {
278
+ assertEquals(0x7f080001, MediaNotificationPolicy.smallIconRes(0x7f080001))
279
+ }
280
+
281
+ @Test
282
+ fun smallIcon_missingDrawable_fallsBackToPlatformPlayGlyph() {
283
+ assertEquals(android.R.drawable.ic_media_play, MediaNotificationPolicy.smallIconRes(0))
284
+ }
285
+ }
@@ -0,0 +1,54 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import androidx.media3.common.Player
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.annotation.Config
10
+
11
+ @RunWith(RobolectricTestRunner::class)
12
+ @Config(sdk = [28])
13
+ class VideoHandoffNotificationCommandsTest {
14
+
15
+ @Test
16
+ fun videoHandoff_playerCommandsHideSkipButKeepPlayPause() {
17
+ val commands = MediaNotificationPolicy.playerCommandsForMediaNotification(
18
+ Player.Commands.EMPTY,
19
+ MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
20
+ previousAvailable = true,
21
+ videoHandoffForegroundRetain = true,
22
+ videoHandoffPlayerAttached = true
23
+ ),
24
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
25
+ nextAvailable = true,
26
+ videoHandoffForegroundRetain = true,
27
+ videoHandoffPlayerAttached = true
28
+ )
29
+ )
30
+ assertTrue(commands.contains(Player.COMMAND_PLAY_PAUSE))
31
+ assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
32
+ assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
33
+ }
34
+
35
+ @Test
36
+ fun retainWithoutVideo_playerCommandsExposeSkipWhenAvailable() {
37
+ val commands = MediaNotificationPolicy.playerCommandsForMediaNotification(
38
+ Player.Commands.EMPTY,
39
+ MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
40
+ previousAvailable = true,
41
+ videoHandoffForegroundRetain = true,
42
+ videoHandoffPlayerAttached = false
43
+ ),
44
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
45
+ nextAvailable = true,
46
+ videoHandoffForegroundRetain = true,
47
+ videoHandoffPlayerAttached = false
48
+ )
49
+ )
50
+ assertTrue(commands.contains(Player.COMMAND_PLAY_PAUSE))
51
+ assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
52
+ assertTrue(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
53
+ }
54
+ }
@@ -0,0 +1,47 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class VideoHandoffNotificationPolicyTest {
8
+
9
+ @Test
10
+ fun retainWithoutVideo_stillIgnoresSessionPlay() {
11
+ assertTrue(MediaNotificationPolicy.shouldIgnoreSessionPlay(true, videoHandoffPlayerAttached = false))
12
+ }
13
+
14
+ @Test
15
+ fun retainWithVideo_doesNotIgnoreSessionPlay() {
16
+ assertFalse(MediaNotificationPolicy.shouldIgnoreSessionPlay(true, videoHandoffPlayerAttached = true))
17
+ }
18
+
19
+ @Test
20
+ fun videoHandoff_hidesSkipCommandsEvenWhenPlaylistWouldAllow() {
21
+ assertFalse(
22
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
23
+ nextAvailable = true,
24
+ videoHandoffForegroundRetain = true,
25
+ videoHandoffPlayerAttached = true
26
+ )
27
+ )
28
+ assertFalse(
29
+ MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
30
+ previousAvailable = true,
31
+ videoHandoffForegroundRetain = true,
32
+ videoHandoffPlayerAttached = true
33
+ )
34
+ )
35
+ }
36
+
37
+ @Test
38
+ fun playlistSkipRestoredWhenVideoDetached() {
39
+ assertTrue(
40
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
41
+ nextAvailable = true,
42
+ videoHandoffForegroundRetain = true,
43
+ videoHandoffPlayerAttached = false
44
+ )
45
+ )
46
+ }
47
+ }