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
@@ -1,117 +1,309 @@
1
1
  package org.dwbn.plugins.playlist.service
2
2
 
3
+ import android.annotation.SuppressLint
3
4
  import android.app.Notification
4
- import android.app.Service
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
5
8
  import android.content.Intent
6
9
  import android.content.pm.ServiceInfo
7
10
  import android.os.Build
8
11
  import android.util.Log
9
- import com.devbrackets.android.playlistcore.components.playlisthandler.PlaylistHandler
10
- import com.devbrackets.android.playlistcore.service.BasePlaylistService
12
+ import androidx.annotation.OptIn
13
+ import androidx.annotation.RequiresApi
14
+ import androidx.core.app.NotificationCompat
15
+ import androidx.media3.common.AudioAttributes
16
+ import androidx.media3.common.C
17
+ import androidx.media3.common.util.UnstableApi
18
+ import androidx.media3.exoplayer.ExoPlayer
19
+ import androidx.media3.session.DefaultMediaNotificationProvider
20
+ import androidx.media3.session.MediaSession
21
+ import androidx.media3.session.MediaSessionService
22
+ import androidx.media3.session.MediaStyleNotificationHelper
23
+ import com.google.common.util.concurrent.Futures
24
+ import com.google.common.util.concurrent.ListenableFuture
25
+ import org.dwbn.plugins.playlist.FakeR
11
26
  import org.dwbn.plugins.playlist.PlaylistRuntime
12
- import org.dwbn.plugins.playlist.data.AudioTrack
27
+ import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge
13
28
  import org.dwbn.plugins.playlist.manager.PlaylistManager
14
- import org.dwbn.plugins.playlist.playlist.AudioApi
15
- import org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler
16
- import org.dwbn.plugins.playlist.service.MediaImageProvider.OnImageUpdatedListener
17
29
 
18
30
  /**
19
- * A simple service that extends [BasePlaylistService] in order to provide
20
- * the application specific information required.
31
+ * Media3 [MediaSessionService] hosting one [ExoPlayer] and one [MediaSession] for audio playlists.
32
+ * Foreground notification is owned by [DefaultMediaNotificationProvider] after the session exists.
21
33
  */
22
- class MediaService : BasePlaylistService<AudioTrack, PlaylistManager>() {
34
+ @OptIn(UnstableApi::class)
35
+ class MediaService : MediaSessionService() {
23
36
  companion object {
24
37
  private const val TAG = "MediaService"
25
38
 
26
39
  @JvmStatic
27
40
  @Volatile
28
41
  var instance: MediaService? = null
42
+
43
+ /** Value [onUpdateNotificationAsync] must pass to super (retain forces FGS past Media3's 10-min cap). */
44
+ @JvmStatic
45
+ fun resolveHandoffNotificationForeground(retain: Boolean, media3Requested: Boolean): Boolean =
46
+ MediaNotificationPolicy.startInForegroundRequired(retain, media3Requested)
29
47
  }
30
48
 
49
+ private var exoPlayer: ExoPlayer? = null
50
+ private var mediaSession: MediaSession? = null
51
+ private var notificationProvider: DefaultMediaNotificationProvider? = null
52
+ private var inForeground = false
53
+
54
+ private val playlistManager: PlaylistManager
55
+ get() = PlaylistRuntime.getPlaylistManager(applicationContext)
56
+
31
57
  override fun onCreate() {
32
- instance = this
33
58
  super.onCreate()
34
- // Adds the audio player implementation, otherwise there's nothing to play media with
35
- val newAudio = AudioApi(applicationContext)
36
- newAudio.addErrorListener(playlistManager)
37
- playlistManager.mediaPlayers.add(newAudio)
38
- }
59
+ instance = this
60
+ // startForegroundService timeout starts before onCreate; player/session setup can exceed it.
61
+ startForegroundImmediately()
62
+ setListener(foregroundStartListener)
63
+ val notificationProvider = DefaultMediaNotificationProvider.Builder(this).build()
64
+ notificationProvider.setSmallIcon(smallIconRes())
65
+ this.notificationProvider = notificationProvider
66
+ setMediaNotificationProvider(notificationProvider)
67
+ setShowNotificationForIdlePlayer(MediaNotificationPolicy.SHOW_NOTIFICATION_WHEN_IDLE)
39
68
 
40
- override fun onDestroy() {
41
- instance = null
42
- super.onDestroy()
69
+ val player = ExoPlayer.Builder(this)
70
+ .setAudioAttributes(
71
+ AudioAttributes.Builder()
72
+ .setUsage(C.USAGE_MEDIA)
73
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
74
+ .build(),
75
+ MediaNotificationPolicy.HANDLE_AUDIO_FOCUS
76
+ )
77
+ .setHandleAudioBecomingNoisy(true)
78
+ .setWakeMode(MediaNotificationPolicy.WAKE_MODE)
79
+ .build()
80
+ exoPlayer = player
81
+ // Load items before the session is built so the notification controller sees a timeline.
82
+ playlistManager.attachPlayer(player)
43
83
 
44
- // Releases and clears all the MediaPlayersMediaImageProvider
45
- for (player in playlistManager.mediaPlayers) {
46
- player.release()
47
- }
48
- playlistManager.mediaPlayers.clear()
84
+ val forwardingPlayer = HandoffForwardingPlayer(
85
+ player,
86
+ retain = { playlistManager.videoHandoffForegroundRetain },
87
+ onSkipToNext = { playlistManager.skipToNext() },
88
+ onSkipToPrevious = { playlistManager.skipToPrevious() },
89
+ previousAvailable = { playlistManager.isPreviousAvailable },
90
+ nextAvailable = { playlistManager.isNextAvailable }
91
+ )
92
+ val sessionBuilder = MediaSession.Builder(this, forwardingPlayer)
93
+ .setId(MediaNotificationPolicy.MEDIA_SESSION_ID)
94
+ .setBitmapLoader(GlideBitmapLoader(this))
95
+ .setCallback(
96
+ PlaylistMediaSessionCallback {
97
+ val retain = playlistManager.videoHandoffForegroundRetain
98
+ val videoAttached = VideoPlayerBridge.hasActivePlayer()
99
+ PlaylistMediaSessionCallback.SkipAvailability(
100
+ MediaNotificationPolicy.mediaNotificationSkipPreviousEnabled(
101
+ playlistManager.isPreviousAvailable,
102
+ retain,
103
+ videoAttached
104
+ ),
105
+ MediaNotificationPolicy.mediaNotificationSkipNextEnabled(
106
+ playlistManager.isNextAvailable,
107
+ retain,
108
+ videoAttached
109
+ )
110
+ )
111
+ }
112
+ )
113
+ sessionActivityPendingIntent()?.let { sessionBuilder.setSessionActivity(it) }
114
+ val session = sessionBuilder.build()
115
+ mediaSession = session
116
+ playlistManager.attachService(this)
117
+
118
+ startForegroundWithMedia3Notification(session)
119
+ // The app drives the player directly, so no MediaController ever connects through
120
+ // onGetSession. Without addSession, Media3 never manages this session's notification and
121
+ // the placeholder above keeps the first title forever (no artist, no track changes).
122
+ addSession(session)
123
+ }
124
+
125
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
126
+ return mediaSession
49
127
  }
50
128
 
129
+ @SuppressLint("NewApi")
51
130
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
52
131
  return try {
53
132
  super.onStartCommand(intent, flags, startId)
54
- } catch (e: android.app.ForegroundServiceStartNotAllowedException) {
55
- // Android 12+ blocks foreground service start from background
56
- // Log the error but don't crash - service can still function without foreground status
57
- Log.w(TAG, "Cannot start foreground service: app is in background", e)
58
- // Return START_NOT_STICKY so service won't be restarted if killed
59
- // The service will continue as a regular service (may be killed by system)
60
- Service.START_NOT_STICKY
133
+ } catch (e: SecurityException) {
134
+ onForegroundStartBlocked(e)
135
+ START_NOT_STICKY
136
+ } catch (e: IllegalStateException) {
137
+ if (MediaNotificationPolicy.isForegroundStartNotAllowedFromBackground(e)) {
138
+ onForegroundStartBlocked(e)
139
+ START_NOT_STICKY
140
+ } else {
141
+ throw e
142
+ }
61
143
  }
62
144
  }
63
145
 
64
- override fun runAsForeground(notificationId: Int, notification: Notification) {
65
- if (inForeground) {
66
- playlistManager.mediaServiceInForeground = true
146
+ override fun onDestroy() {
147
+ clearListener()
148
+ playlistManager.detachPlayer()
149
+ mediaSession?.release()
150
+ mediaSession = null
151
+ exoPlayer?.release()
152
+ exoPlayer = null
153
+ instance = null
154
+ super.onDestroy()
155
+ }
156
+
157
+ fun isRunningInForeground(): Boolean = inForeground
158
+
159
+ /**
160
+ * Media3 owns FGS via [DefaultMediaNotificationProvider]. Do not call [startForeground] here:
161
+ * Android 12+ forbids restarting a foreground service from the background after video.
162
+ */
163
+ fun promoteToForeground() {
164
+ if (!MediaNotificationPolicy.shouldStartForegroundOnPromote()) {
67
165
  return
68
166
  }
167
+ }
69
168
 
70
- inForeground = true
71
- try {
72
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
73
- startForeground(notificationId, notification)
169
+ /**
170
+ * Media3 1.11.1 posts the MediaStyle notification from [onUpdateNotificationAsync].
171
+ * Overriding the 2-arg [onUpdateNotification] and calling it from onCreate does not.
172
+ */
173
+ override fun onUpdateNotificationAsync(
174
+ session: MediaSession,
175
+ startInForegroundRequired: Boolean
176
+ ): ListenableFuture<Void?> {
177
+ val required = resolveHandoffNotificationForeground(
178
+ playlistManager.videoHandoffForegroundRetain,
179
+ startInForegroundRequired
180
+ )
181
+ return try {
182
+ val result = super.onUpdateNotificationAsync(session, required)
183
+ inForeground = required
184
+ playlistManager.mediaServiceInForeground = required
185
+ result
186
+ } catch (e: SecurityException) {
187
+ onForegroundStartBlocked(e)
188
+ Futures.immediateFuture(null)
189
+ } catch (e: IllegalStateException) {
190
+ if (MediaNotificationPolicy.isForegroundStartNotAllowedFromBackground(e)) {
191
+ onForegroundStartBlocked(e)
192
+ Futures.immediateFuture(null)
74
193
  } else {
75
- startForeground(notificationId, notification,
76
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
194
+ throw e
77
195
  }
78
- playlistManager.mediaServiceInForeground = true
79
- } catch (e: android.app.ForegroundServiceStartNotAllowedException) {
80
- // Android 12+ blocks foreground service start from background
81
- // Log the error but don't crash - service can still function without foreground status
82
- Log.w(TAG, "Cannot start foreground service: app is in background", e)
83
- inForeground = false
84
- playlistManager.mediaServiceInForeground = false
85
- // Service will continue as a regular service (may be killed by system)
86
196
  }
87
197
  }
88
198
 
89
- override fun endForeground(removeNotification: Boolean) {
90
- if (playlistManager.videoHandoffForegroundRetain) {
199
+ private fun onForegroundStartBlocked(e: Throwable) {
200
+ Log.w(TAG, "Cannot start foreground service: app is in background", e)
201
+ inForeground = false
202
+ playlistManager.mediaServiceInForeground = false
203
+ }
204
+
205
+ fun endForeground(removeNotification: Boolean) {
206
+ if (MediaNotificationPolicy.shouldSkipEndForeground(playlistManager.videoHandoffForegroundRetain)) {
91
207
  return
92
208
  }
93
- super.endForeground(removeNotification)
209
+ inForeground = false
94
210
  playlistManager.mediaServiceInForeground = false
211
+ if (removeNotification) {
212
+ stopForeground(STOP_FOREGROUND_REMOVE)
213
+ } else {
214
+ stopForeground(STOP_FOREGROUND_DETACH)
215
+ }
95
216
  }
96
217
 
97
- fun isRunningInForeground(): Boolean = inForeground
218
+ fun updateForegroundNotification() {
219
+ triggerNotificationUpdate()
220
+ }
98
221
 
99
- override val playlistManager: PlaylistManager
100
- get() = PlaylistRuntime.getPlaylistManager(applicationContext)
222
+ /** Re-reads `options.icon` after `setOptions` so a running service picks up the app's icon. */
223
+ fun applyNotificationIcon() {
224
+ notificationProvider?.setSmallIcon(smallIconRes())
225
+ triggerNotificationUpdate()
226
+ }
101
227
 
102
- override fun newPlaylistHandler(): PlaylistHandler<AudioTrack> {
103
- val imageProvider = MediaImageProvider(applicationContext, object : OnImageUpdatedListener {
104
- override fun onImageUpdated() {
105
- playlistHandler.updateMediaControls()
106
- }
107
- }, playlistManager.options)
228
+ private fun smallIconRes(): Int =
229
+ MediaNotificationPolicy.smallIconRes(
230
+ FakeR.getId(this, "drawable", playlistManager.options.icon)
231
+ )
232
+
233
+ /** FGS within startForegroundService timeout; replaced by MediaStyle once the session exists. */
234
+ private fun startForegroundImmediately() {
235
+ startForegroundWith(buildImmediateNotification())
236
+ }
237
+
238
+ private fun buildImmediateNotification(): Notification {
239
+ return mediaNotificationBuilder().build()
240
+ }
241
+
242
+ /**
243
+ * Attach the Media3 session token so System UI shows play/pause/skip (API 33+ compact slots).
244
+ * Do not call [onUpdateNotification] here: Media3 1.11.1 only posts via [onUpdateNotificationAsync].
245
+ */
246
+ private fun startForegroundWithMedia3Notification(session: MediaSession) {
247
+ val notification = mediaNotificationBuilder()
248
+ .setStyle(MediaStyleNotificationHelper.MediaStyle(session))
249
+ .build()
250
+ startForegroundWith(notification)
251
+ }
252
+
253
+ private fun mediaNotificationBuilder(): NotificationCompat.Builder {
254
+ val channelId = DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID
255
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
256
+ val channel = NotificationChannel(
257
+ channelId,
258
+ "Audio playback",
259
+ NotificationManager.IMPORTANCE_LOW
260
+ )
261
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
262
+ }
263
+ val title = playlistManager.currentItem?.title?.takeIf { it.isNotEmpty() } ?: "Audio playback"
264
+ val builder = NotificationCompat.Builder(this, channelId)
265
+ .setSmallIcon(smallIconRes())
266
+ .setContentTitle(title)
267
+ .setOngoing(true)
268
+ .setOnlyAlertOnce(true)
269
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
270
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
271
+ sessionActivityPendingIntent()?.let { builder.setContentIntent(it) }
272
+ return builder
273
+ }
274
+
275
+ private fun startForegroundWith(notification: Notification) {
276
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
277
+ startForeground(
278
+ DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID,
279
+ notification,
280
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
281
+ )
282
+ } else {
283
+ startForeground(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID, notification)
284
+ }
285
+ inForeground = true
286
+ playlistManager.mediaServiceInForeground = true
287
+ }
108
288
 
109
- return AudioPlaylistHandler.Builder(
110
- applicationContext,
111
- javaClass,
112
- playlistManager,
113
- imageProvider,
114
- null
115
- ).build()
289
+ private fun sessionActivityPendingIntent(): PendingIntent? {
290
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?: return null
291
+ launchIntent.addFlags(MediaNotificationPolicy.sessionLaunchIntentFlags())
292
+ return PendingIntent.getActivity(
293
+ this,
294
+ 0,
295
+ launchIntent,
296
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
297
+ )
116
298
  }
117
- }
299
+
300
+ private val foregroundStartListener = object : Listener {
301
+ @RequiresApi(Build.VERSION_CODES.S)
302
+ override fun onForegroundServiceStartNotAllowedException() {
303
+ Log.w(TAG, "Cannot start foreground service: app is in background")
304
+ inForeground = false
305
+ playlistManager.mediaServiceInForeground = false
306
+ }
307
+ }
308
+
309
+ }
@@ -0,0 +1,39 @@
1
+ package org.dwbn.plugins.playlist.service
2
+
3
+ import androidx.annotation.OptIn
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.session.MediaSession
6
+
7
+ /**
8
+ * Exposes play/pause and playlist skip commands to the system media notification controller
9
+ * without stripping Media3's default metadata commands.
10
+ */
11
+ @OptIn(UnstableApi::class)
12
+ internal class PlaylistMediaSessionCallback(
13
+ private val skipAvailability: () -> SkipAvailability
14
+ ) : MediaSession.Callback {
15
+
16
+ data class SkipAvailability(
17
+ val previousAvailable: Boolean,
18
+ val nextAvailable: Boolean
19
+ )
20
+
21
+ override fun onConnect(
22
+ session: MediaSession,
23
+ controller: MediaSession.ControllerInfo
24
+ ): MediaSession.ConnectionResult {
25
+ if (session.isMediaNotificationController(controller)) {
26
+ val availability = skipAvailability()
27
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session, controller)
28
+ .setAvailablePlayerCommands(
29
+ MediaNotificationPolicy.playerCommandsForMediaNotification(
30
+ MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
31
+ availability.previousAvailable,
32
+ availability.nextAvailable
33
+ )
34
+ )
35
+ .build()
36
+ }
37
+ return super.onConnect(session, controller)
38
+ }
39
+ }
@@ -0,0 +1,98 @@
1
+ package org.dwbn.plugins.playlist
2
+
3
+ import androidx.media3.common.MimeTypes
4
+ import androidx.media3.common.PlaybackException
5
+ import org.dwbn.plugins.playlist.data.AudioTrack
6
+ import org.json.JSONObject
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Test
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.RobolectricTestRunner
12
+ import org.robolectric.annotation.Config
13
+
14
+ @RunWith(RobolectricTestRunner::class)
15
+ @Config(sdk = [28])
16
+ class AudioMediaItemFactoryMimeTest {
17
+
18
+ @Test
19
+ fun extensionlessStream_setsHlsMimeOnMediaItem() {
20
+ val item = AudioMediaItemFactory.fromUrl("https://example.com/stream/audio", true)
21
+ assertEquals(MimeTypes.APPLICATION_M3U8, item.localConfiguration?.mimeType)
22
+ }
23
+
24
+ @Test
25
+ fun m3u8Url_setsHlsMimeOnMediaItem() {
26
+ val item = AudioMediaItemFactory.fromUrl("https://example.com/audio.m3u8", false)
27
+ assertEquals(MimeTypes.APPLICATION_M3U8, item.localConfiguration?.mimeType)
28
+ }
29
+
30
+ @Test
31
+ fun mp3Url_doesNotForceHlsMimeOnMediaItem() {
32
+ val item = AudioMediaItemFactory.fromUrl("https://example.com/lecture.mp3", false)
33
+ assertNull(item.localConfiguration?.mimeType)
34
+ }
35
+
36
+ @Test
37
+ fun fromAudioTrack_setsHlsMimeForExtensionlessStream() {
38
+ val json = JSONObject()
39
+ json.put("assetUrl", "https://example.com/stream/audio")
40
+ json.put("isStream", true)
41
+ val item = AudioMediaItemFactory.fromAudioTrack(AudioTrack(json))
42
+ assertEquals(MimeTypes.APPLICATION_M3U8, item.localConfiguration?.mimeType)
43
+ }
44
+
45
+ @Test
46
+ fun fromAudioTrack_emptyTitle_fallsBackToAudioPlayback() {
47
+ val json = JSONObject()
48
+ json.put("assetUrl", "https://example.com/lecture.mp3")
49
+ val item = AudioMediaItemFactory.fromAudioTrack(AudioTrack(json))
50
+ assertEquals("Audio playback", item.mediaMetadata.title.toString())
51
+ }
52
+
53
+ @Test
54
+ fun fromAudioTrack_setsArtworkUriFromAlbumArt() {
55
+ val json = JSONObject()
56
+ json.put("assetUrl", "https://example.com/lecture.mp3")
57
+ json.put("albumArt", "https://example.com/art.jpg")
58
+ json.put("title", "Lecture")
59
+ val item = AudioMediaItemFactory.fromAudioTrack(AudioTrack(json))
60
+ assertEquals("https://example.com/art.jpg", item.mediaMetadata.artworkUri.toString())
61
+ assertEquals("Lecture", item.mediaMetadata.title.toString())
62
+ }
63
+
64
+ @Test
65
+ fun nullUrl_doesNotThrow() {
66
+ val item = AudioMediaItemFactory.fromUrl(null, true)
67
+ assertNull(item.localConfiguration?.mimeType)
68
+ }
69
+ }
70
+
71
+ class RmxPlaybackErrorMapperTest {
72
+
73
+ @Test
74
+ fun decoderErrors_mapToDecode() {
75
+ assertEquals(
76
+ RmxAudioErrorType.RMXERR_DECODE,
77
+ RmxPlaybackErrorMapper.fromMedia3ErrorCode(PlaybackException.ERROR_CODE_DECODER_INIT_FAILED)
78
+ )
79
+ assertEquals(
80
+ RmxAudioErrorType.RMXERR_DECODE,
81
+ RmxPlaybackErrorMapper.fromMedia3ErrorCode(PlaybackException.ERROR_CODE_DECODING_FAILED)
82
+ )
83
+ assertEquals(
84
+ RmxAudioErrorType.RMXERR_DECODE,
85
+ RmxPlaybackErrorMapper.fromMedia3ErrorCode(PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED)
86
+ )
87
+ }
88
+
89
+ @Test
90
+ fun ioErrors_mapToNoneSupported() {
91
+ assertEquals(
92
+ RmxAudioErrorType.RMXERR_NONE_SUPPORTED,
93
+ RmxPlaybackErrorMapper.fromMedia3ErrorCode(
94
+ PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
95
+ )
96
+ )
97
+ }
98
+ }
@@ -0,0 +1,71 @@
1
+ package org.dwbn.plugins.playlist;
2
+
3
+ import static org.junit.Assert.assertEquals;
4
+ import static org.junit.Assert.assertFalse;
5
+ import static org.junit.Assert.assertNull;
6
+ import static org.junit.Assert.assertTrue;
7
+
8
+ import org.dwbn.plugins.playlist.data.AudioTrack;
9
+ import org.json.JSONObject;
10
+ import org.junit.Test;
11
+
12
+ public class AudioMediaItemFactoryTest {
13
+
14
+ @Test
15
+ public void m3u8Url_shouldUseHlsMimeType() {
16
+ assertTrue(AudioMediaItemFactory.shouldUseHlsMimeType("https://example.com/audio.m3u8", false));
17
+ }
18
+
19
+ @Test
20
+ public void extensionlessStream_shouldUseHlsMimeType() {
21
+ assertTrue(AudioMediaItemFactory.shouldUseHlsMimeType("https://example.com/stream/audio", true));
22
+ }
23
+
24
+ @Test
25
+ public void mp3Url_shouldNotForceHlsMimeType() {
26
+ assertFalse(AudioMediaItemFactory.shouldUseHlsMimeType("https://example.com/lecture.mp3", false));
27
+ }
28
+
29
+ @Test
30
+ public void albumArt_mapsToThumbnailUrlForArtworkUri() throws Exception {
31
+ JSONObject json = new JSONObject();
32
+ json.put("assetUrl", "https://example.com/lecture.mp3");
33
+ json.put("albumArt", "https://example.com/art.jpg");
34
+ json.put("title", "Lecture");
35
+ json.put("artist", "Lama Ole");
36
+ json.put("album", "Awareness");
37
+ AudioTrack track = new AudioTrack(json);
38
+
39
+ assertEquals("https://example.com/art.jpg", AudioMediaItemFactory.artworkUriString(track));
40
+ assertTrue(
41
+ org.dwbn.plugins.playlist.service.GlideBitmapLoader.shouldLoadRemoteArtwork(
42
+ AudioMediaItemFactory.artworkUriString(track)));
43
+ }
44
+
45
+ @Test
46
+ public void missingAlbumArt_doesNotLoadRemoteArtwork() throws Exception {
47
+ JSONObject json = new JSONObject();
48
+ json.put("assetUrl", "https://example.com/lecture.mp3");
49
+ json.put("title", "Lecture");
50
+ AudioTrack track = new AudioTrack(json);
51
+
52
+ assertNull(AudioMediaItemFactory.artworkUriString(track));
53
+ assertFalse(
54
+ org.dwbn.plugins.playlist.service.GlideBitmapLoader.shouldLoadRemoteArtwork(
55
+ AudioMediaItemFactory.artworkUriString(track)));
56
+ }
57
+
58
+ @Test
59
+ public void emptyAlbumArt_doesNotLoadRemoteArtwork() throws Exception {
60
+ JSONObject json = new JSONObject();
61
+ json.put("assetUrl", "https://example.com/lecture.mp3");
62
+ json.put("albumArt", "");
63
+ json.put("title", "Lecture");
64
+ AudioTrack track = new AudioTrack(json);
65
+
66
+ assertNull(AudioMediaItemFactory.artworkUriString(track));
67
+ assertFalse(org.dwbn.plugins.playlist.service.GlideBitmapLoader.shouldLoadRemoteArtwork(""));
68
+ assertFalse(org.dwbn.plugins.playlist.service.GlideBitmapLoader.shouldLoadRemoteArtwork(null));
69
+ assertNull(AudioMediaItemFactory.artworkUriString(null));
70
+ }
71
+ }
@@ -0,0 +1,53 @@
1
+ package org.dwbn.plugins.playlist.data
2
+
3
+ import org.json.JSONObject
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Test
6
+
7
+ /** Coverage for [AudioTrack.startPositionMs] — the resume-position fix for skipToNext/Previous. */
8
+ class AudioTrackTest {
9
+
10
+ private fun trackConfig(startPosition: Double? = null): JSONObject {
11
+ val json = JSONObject()
12
+ json.put("assetUrl", "https://example.com/a.mp3")
13
+ json.put("artist", "Artist")
14
+ json.put("album", "Album")
15
+ json.put("title", "Title")
16
+ if (startPosition != null) {
17
+ json.put("startPosition", startPosition)
18
+ }
19
+ return json
20
+ }
21
+
22
+ @Test
23
+ fun startPositionMs_parsesSecondsFromJsAsMilliseconds() {
24
+ val track = AudioTrack(trackConfig(startPosition = 58.294))
25
+
26
+ assertEquals(58294L, track.startPositionMs)
27
+ }
28
+
29
+ @Test
30
+ fun startPositionMs_defaultsToZeroWhenAbsent() {
31
+ val track = AudioTrack(trackConfig(startPosition = null))
32
+
33
+ assertEquals(0L, track.startPositionMs)
34
+ }
35
+
36
+ @Test
37
+ fun startPositionMs_canBeUpdatedAfterConstruction() {
38
+ val track = AudioTrack(trackConfig(startPosition = 10.0))
39
+
40
+ track.startPositionMs = 42_000L
41
+
42
+ assertEquals(42_000L, track.startPositionMs)
43
+ }
44
+
45
+ @Test
46
+ fun startPositionMs_clampsNegativeToZero() {
47
+ val track = AudioTrack(trackConfig(startPosition = 10.0))
48
+
49
+ track.startPositionMs = -5L
50
+
51
+ assertEquals(0L, track.startPositionMs)
52
+ }
53
+ }