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.
- package/README.md +133 -127
- package/android/build.gradle +31 -18
- package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/android/src/main/java/org/dwbn/plugins/playlist/AudioMediaItemFactory.java +96 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +3 -3
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioPlayer.java +145 -188
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxPlaybackErrorMapper.kt +26 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +28 -28
- package/android/src/main/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridge.kt +102 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaybackProgress.kt +8 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +418 -183
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicy.kt +98 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/RmxPlaybackState.kt +11 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandler.java +107 -211
- package/android/src/main/java/org/dwbn/plugins/playlist/service/GlideBitmapLoader.kt +121 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayer.kt +185 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicy.kt +253 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaService.kt +260 -68
- package/android/src/main/java/org/dwbn/plugins/playlist/service/PlaylistMediaSessionCallback.kt +39 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryMimeTest.kt +98 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryTest.java +71 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/data/AudioTrackTest.kt +53 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridgeTest.kt +84 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicyTest.kt +128 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandlerTest.kt +145 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/GlideBitmapLoaderTest.kt +76 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayerTest.kt +282 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicyTest.kt +285 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationCommandsTest.kt +54 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationPolicyTest.kt +47 -0
- package/dist/docs.json +12 -846
- package/dist/esm/RmxAudioPlayer.d.ts +2 -2
- package/dist/esm/RmxAudioPlayer.js.map +1 -1
- package/dist/esm/definitions.d.ts +7 -7
- package/dist/esm/interfaces.d.ts +7 -1
- package/dist/esm/utils.d.ts +1 -1
- package/dist/esm/utils.js +2 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +3 -3
- package/dist/esm/web.js +5 -5
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +7 -7
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +7 -7
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/PlaylistPlugin/AVBidirectionalQueuePlayer.swift +45 -6
- package/ios/Sources/PlaylistPlugin/AudioTrack.swift +15 -2
- package/ios/Tests/PlaylistPluginTests/PositionResumeTests.swift +82 -0
- package/package.json +15 -19
- package/android/src/main/java/org/dwbn/plugins/playlist/notification/PlaylistNotificationProvider.kt +0 -26
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioApi.kt +0 -114
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/BaseMediaApi.kt +0 -36
- 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.
|
|
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
|
|
10
|
-
import
|
|
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.
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
218
|
+
fun updateForegroundNotification() {
|
|
219
|
+
triggerNotificationUpdate()
|
|
220
|
+
}
|
|
98
221
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
package/android/src/main/java/org/dwbn/plugins/playlist/service/PlaylistMediaSessionCallback.kt
ADDED
|
@@ -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
|
+
}
|