capacitor-plugin-playlist 0.11.4 → 0.13.1

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 +20 -15
  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 +425 -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
package/README.md CHANGED
@@ -4,20 +4,33 @@ Capacitor plugin for **Android**, **iOS**, and **Web** with native audio playlis
4
4
 
5
5
  Requires **Capacitor 8+** (peer dependency `@capacitor/core >= 8.0.0`).
6
6
 
7
+ ## Versioning
8
+
9
+ This plugin’s version number is **independent of the Capacitor major**. Many Capacitor plugins use `8.x` when they target Capacitor 8; this one stays on **0.x** and declares `@capacitor/core >= 8.0.0` as a peer. Pick a release by feature line (see table below).
10
+
11
+ | Plugin version | Meaning | Capacitor peer |
12
+ |----------------|---------|----------------|
13
+ | **0.11.x** | Last line on [npm](https://www.npmjs.com/package/capacitor-plugin-playlist) today (**0.11.4**). Android: ExoMedia + PlaylistCore. | 8+ |
14
+ | **0.12.0** | Current release. Media3 Android stack (`MediaSessionService`); Capacitor 8+ peer; Capacitor JS API unchanged from 0.11.x. | 8+ |
15
+
16
+ Older **0.8.x–0.10.x** entries in [CHANGELOG.md](./CHANGELOG.md) are historical release notes, not a target to “stay on 0.8 for Capacitor 8.”
17
+
7
18
  ## Index
8
19
 
9
20
  1. [Features](#features)
10
- 2. [Background](#background)
11
- 3. [Notes](#notes)
12
- 4. [Installation](#installation)
13
- 5. [Usage](#usage)
14
- 6. [Events](#events)
15
- 7. [Video handoff](#video-handoff)
16
- 8. [API](#api)
17
- 9. [Migrating from cordova-plugin-playlist](#migrating-from-cordova-plugin-playlist)
18
- 10. [Changes](#changes)
19
- 11. [Credits](#credits)
20
- 12. [License](#license)
21
+ 2. [Versioning](#versioning)
22
+ 3. [Background](#background)
23
+ 4. [Notes](#notes)
24
+ 5. [Installation](#installation)
25
+ 6. [Upgrading a host app (0.12.0)](#upgrading-a-host-app-0120)
26
+ 7. [Usage](#usage)
27
+ 8. [Events](#events)
28
+ 9. [Video handoff](#video-handoff)
29
+ 10. [API](#api)
30
+ 11. [Migrating from cordova-plugin-playlist](#migrating-from-cordova-plugin-playlist)
31
+ 12. [Changes](#changes)
32
+ 13. [Credits](#credits)
33
+ 14. [License](#license)
21
34
 
22
35
  ## Features
23
36
 
@@ -45,7 +58,7 @@ Requires **Capacitor 8+** (peer dependency `@capacitor/core >= 8.0.0`).
45
58
 
46
59
  | Platform | Engine | OS controls |
47
60
  |----------|--------|-------------|
48
- | Android | [ExoMedia](https://github.com/brianwernick/ExoMedia) + [PlaylistCore](https://github.com/brianwernick/PlaylistCore) | MediaStyle notification, MediaSession, foreground `mediaPlayback` service |
61
+ | Android | [androidx.media3](https://developer.android.com/jetpack/androidx/releases/media3) **1.11.1** (`ExoPlayer` + `MediaSessionService` + `DefaultMediaNotificationProvider`) | MediaStyle notification, MediaSession, foreground `mediaPlayback` service |
49
62
  | iOS | Custom `AVBidirectionalQueuePlayer` | Lock screen + Control Center via `MPNowPlayingInfoCenter` / `MPRemoteCommandCenter` |
50
63
  | Web | HTMLAudioElement + optional HLS.js | Browser media controls only |
51
64
 
@@ -97,7 +110,7 @@ Forked from [cordova-plugin-playlist](https://github.com/Rolamix/cordova-plugin-
97
110
 
98
111
  ### Android
99
112
 
100
- Uses ExoMedia (ExoPlayer wrapper) with PlaylistCore for notification and MediaSession integration.
113
+ Uses Media3 `ExoPlayer` inside `MediaService` (`MediaSessionService`). Notification and lock-screen controls come from Media3 `DefaultMediaNotificationProvider`. Media session id is **`org.dwbn.playlist`**. The notification channel (`Audio playback`) is created by the plugin — hosts do not configure it.
101
114
 
102
115
  ### iOS
103
116
 
@@ -147,24 +160,7 @@ Keep your application's existing Android `Application` class. The legacy `org.dw
147
160
 
148
161
  #### Gradle 9+
149
162
 
150
- Ensure the Kotlin plugin is declared in your root `android/build.gradle` (this plugin no longer ships its own `buildscript` block):
151
-
152
- ```gradle
153
- buildscript {
154
- ext.kotlin_version = '2.3.0'
155
- repositories {
156
- google()
157
- mavenCentral()
158
- }
159
- dependencies {
160
- classpath 'com.android.tools.build:gradle:8.13.2'
161
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
162
- }
163
- }
164
- ext {
165
- kotlin_version = '2.3.0'
166
- }
167
- ```
163
+ On **AGP 9+**, Kotlin is built into the Android Gradle Plugin — do **not** add an extra `kotlin-android` plugin in your host app because of this plugin (see [CHANGELOG 0.11.4](./CHANGELOG.md)). Match your project's existing AGP/Kotlin setup.
168
164
 
169
165
  #### Glide (notification album art)
170
166
 
@@ -213,6 +209,61 @@ Add to `Info.plist`:
213
209
 
214
210
  Without `audio` background mode, iOS stops playback when the app backgrounds.
215
211
 
212
+ ## Upgrading a host app (0.12.0)
213
+
214
+ **Capacitor / JavaScript:** no changes — `Playlist`, `RmxAudioPlayer`, status events, and video handoff method names behave as before.
215
+
216
+ **Android host app:** update Gradle and manifest as below, then `npx cap sync android`.
217
+
218
+ ### From 0.11.x → 0.12.0
219
+
220
+ For apps on the last **npm** line (**0.11.4**) moving to **0.12.0**:
221
+
222
+ **Remove from the host app** (if you added these when following older docs or samples):
223
+
224
+ - `implementation` of `com.devbrackets.android:playlistcore` and `com.devbrackets.android:exomedia` — 0.12.0 uses Media3 inside the plugin; leftover host deps pull an old Media3 version
225
+ - `android:name="org.dwbn.plugins.playlist.App"` on `<application>` — unused since 0.11.0; keep your own `Application` class
226
+
227
+ **Stop doing** on 0.12.0: subclassing PlaylistCore, calling `startForeground` beside `MediaService`, or adding `com.google.android.exoplayer:exoplayer-*:2.x` in the app module.
228
+
229
+ **Then apply** the steps under [Always (Android 0.12.0+)](#always-android-0120) below.
230
+
231
+ **Video handoff:** from 0.12.0, foreground retain starts on `prepareForVideoHandoff`. Existing JS that uses `prewarm: true` still works; prewarm is optional, not required for every flow.
232
+
233
+ ### Always (Android 0.12.0+)
234
+
235
+ 1. Bump the plugin to **0.12.0** (or newer) and run `npx cap sync android`.
236
+ 2. **Pin one Media3 version** when the same APK also uses another Media3 library (for example a native fullscreen video player). Gradle can otherwise unify to an old transitive (pre-0.12.0 ExoMedia pulled Media3 1.5.1). In `android/variables.gradle` (or `ext`):
237
+
238
+ ```gradle
239
+ media3Version = '1.11.1'
240
+ ```
241
+
242
+ In the root `android/build.gradle`:
243
+
244
+ ```gradle
245
+ allprojects {
246
+ configurations.configureEach {
247
+ resolutionStrategy {
248
+ eachDependency { details ->
249
+ if (details.requested.group == 'androidx.media3') {
250
+ details.useVersion rootProject.ext.media3Version
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ ```
257
+
258
+ 3. **Android 13+ (API 33+):** declare `android.permission.POST_NOTIFICATIONS` in your **host** `AndroidManifest.xml` and request it at runtime when not granted. This plugin does not merge that permission — without it, media notifications may not appear.
259
+ 4. Minimum SDK **24** (unchanged).
260
+
261
+ ### Audio + video in one app
262
+
263
+ If the same Android app plays background audio with this plugin **and** native video with Media3, use **one** `media3Version` in Gradle (snippet above). Mixed Media3 versions in one APK are unsupported.
264
+
265
+ This plugin’s MediaSession id is **`org.dwbn.playlist`**. Any second Media3 session in the same process (typical for a fullscreen video player) must use a **different** id — **`org.dwbn.video`** is the usual pair for DWBN handoff setups. Media3 forbids two empty or duplicate session ids.
266
+
216
267
  ## Usage
217
268
 
218
269
  See also `examples/audio-provider.ts` for an Angular/Ionic integration.
@@ -317,9 +368,9 @@ Works with any native Capacitor video plugin (or other player) that needs exclus
317
368
 
318
369
  | Method | Purpose |
319
370
  |--------|---------|
320
- | `prepareForVideoHandoff()` | Pause audio, capture head position, release audio focus / session |
371
+ | `prepareForVideoHandoff()` | Pause audio, capture head position, release audio focus / session; Android retains FGS |
321
372
  | `getLastKnownPosition()` | Read captured position (seconds) after prepare |
322
- | `resumeAfterVideoHandoff({ position, prewarm? })` | Re-arm audio after video, or prewarm Android FGS before video |
373
+ | `resumeAfterVideoHandoff({ position, prewarm? })` | Re-arm audio after video, or silently prepare at position (optional Android prewarm) |
323
374
 
324
375
  Call these on the `Playlist` plugin directly — they are **not** exposed on `RmxAudioPlayer`.
325
376
 
@@ -336,7 +387,7 @@ sequenceDiagram
336
387
  Playlist-->>App: audio paused, focus released
337
388
  App->>Playlist: getLastKnownPosition() optional
338
389
  App->>Playlist: resumeAfterVideoHandoff position prewarm true
339
- Note over Playlist: Android only silent FGS prewarm
390
+ Note over Playlist: Android only optional silent prepare
340
391
  App->>VideoPlayer: initPlayer
341
392
  VideoPlayer-->>App: video playing
342
393
 
@@ -360,13 +411,17 @@ await nativeVideoPlayer.init({ /* url, fullscreen, … */ });
360
411
 
361
412
  // --- Exiting native video (use video head, not audioPosition) ---
362
413
  const videoPosition = 120; // from your video player's position events
363
- await Playlist.resumeAfterVideoHandoff({ position: videoPosition });
364
- await Playlist.play(); // if user should resume audible playback
414
+ const { resumed } = await Playlist.resumeAfterVideoHandoff({ position: videoPosition });
415
+ if (!resumed) {
416
+ await Playlist.play(); // in-place Android resume already played; skip to avoid a stutter
417
+ }
365
418
  ```
366
419
 
367
- ### Recommended Android sequence (with prewarm)
420
+ ### Optional Android sequence (prewarm)
368
421
 
369
- On Android 14+ (especially Android 17), starting or re-promoting the media foreground service from the background can fail or mute playback. **Prewarm while the app is still visible** before video starts:
422
+ From **0.12.0**, the foreground service is retained when you call `prepareForVideoHandoff`, so many apps can use the [basic sequence](#basic-sequence) only. On older Android versions or if you still see focus/FGS edge cases, you may **optionally** prewarm before video:
423
+
424
+ On Android 14+ (especially Android 17), starting or re-promoting the media foreground service from the background can still fail in some flows. **Prewarm while the app is still visible** if you need it:
370
425
 
371
426
  ```typescript
372
427
  // 1. Release audio focus
@@ -384,14 +439,16 @@ await nativeVideoPlayer.init({ /* … */ });
384
439
  // … user watches video; track video position via player events …
385
440
 
386
441
  // 4. After video closes — re-arm without prewarm
387
- await Playlist.resumeAfterVideoHandoff({ position: Math.floor(videoExitSec) });
388
- await Playlist.play(); // when ready
442
+ const { resumed } = await Playlist.resumeAfterVideoHandoff({ position: Math.floor(videoExitSec) });
443
+ if (!resumed) {
444
+ await Playlist.play();
445
+ }
389
446
  ```
390
447
 
391
448
  **What `prewarm: true` does on Android:**
392
449
 
393
- - Promotes `MediaService` to foreground (`mediaPlayback` FGS) while the app is foregrounded
394
450
  - Prepares the playlist item at `position` but stays **silent** — no audio focus request, no audible playback
451
+ - Does **not** create FGS; retain already started on `prepareForVideoHandoff`
395
452
  - Prevents video sound from dropping when audio would otherwise re-request focus
396
453
  - During prewarm, `Playlist.play()` is a no-op for audible playback (only keeps FGS notification updated)
397
454
 
@@ -403,11 +460,11 @@ await Playlist.play(); // when ready
403
460
 
404
461
  | Step | Android | iOS | Web |
405
462
  |------|---------|-----|-----|
406
- | `prepareForVideoHandoff` | Pause, abandon audio focus, store position via `MediaProgress` | Pause, store track time, `AVAudioSession.setActive(false)` | Pause HTMLAudioElement, store `currentTime` |
463
+ | `prepareForVideoHandoff` | Pause, abandon audio focus, store position, retain FGS (do not stop) | Pause, store track time, `AVAudioSession.setActive(false)` | Pause HTMLAudioElement, store `currentTime` |
407
464
  | `getLastKnownPosition` | Returns stored handoff position (seconds) | Same | Same |
408
- | `resumeAfterVideoHandoff` (no prewarm) | Re-request focus; in-place resume if FGS still foreground from prewarm, else `beginPlayback` | Reactivate audio session; reset track-id guard for PLAYING events | Store position only |
409
- | `resumeAfterVideoHandoff` (prewarm) | Silent FGS + prepare at position, no focus/play | No-op | N/A |
410
- | After resume | Call `play()` to start audible playback | Call `play()` to start audible playback | Call `play()` on web player |
465
+ | `resumeAfterVideoHandoff` (no prewarm) | In-place play-then-seek if FGS still foreground (retain starts on `prepareForVideoHandoff`); otherwise `{ resumed: false }` so JS may play/seek | Reactivate audio session; reset track-id guard for PLAYING events | Store position only |
466
+ | `resumeAfterVideoHandoff` (prewarm) | Silent prepare at position; FGS already retained from prepare; `{ resumed: false }` | No-op | N/A |
467
+ | After resume | If `{ resumed: true }`, skip `play()` / `seekTo`. If `false`, JS may play/seek | Call `play()` if audible resume is wanted | Call `play()` on web player |
411
468
 
412
469
  ### Position: audio vs video head
413
470
 
@@ -418,8 +475,8 @@ await Playlist.play(); // when ready
418
475
  ### Integration checklist
419
476
 
420
477
  1. Call `prepareForVideoHandoff()` **immediately before** your native video player starts — never after.
421
- 2. On Android, call `resumeAfterVideoHandoff({ position, prewarm: true })` **after prepare and before video init**, while the WebView is still foregrounded.
422
- 3. On video exit, call `resumeAfterVideoHandoff({ position })` **without** `prewarm`, then `play()` if playback should resume.
478
+ 2. **(Optional, Android)** Call `resumeAfterVideoHandoff({ position, prewarm: true })` after prepare and before video init while the WebView is foregrounded — only if you need prewarm (see [0.12.0 retain](#platform-behaviour)).
479
+ 3. On video exit, call `resumeAfterVideoHandoff({ position })` **without** `prewarm`. If `{ resumed: true }`, skip `play()` / `seekTo`; if `false`, JS may play/seek.
423
480
  4. `resumeAfterVideoHandoff` must complete before `seekTo()` / `play()` on the audio side.
424
481
  5. Do not call `Playlist.release()` between prepare and resume unless you intend to tear down the native player entirely.
425
482
  6. Idempotent exit handling: guard against duplicate `resumeAfterVideoHandoff` calls from concurrent native exit events (merge to a single call with `max(position)`).
@@ -428,8 +485,8 @@ await Playlist.play(); // when ready
428
485
 
429
486
  | Symptom | Likely cause |
430
487
  |---------|----------------|
431
- | Video has no sound shortly after start | Audio re-requested focus after prepare; use Android `prewarm: true` before video starts |
432
- | Audio silent after long video session | FGS stopped while backgrounded; prewarm before video + in-place resume on exit (0.8.10+) |
488
+ | Video has no sound shortly after start | Audio re-requested focus after prepare; try optional Android `prewarm: true` before video, or ensure 0.12.0+ retain on `prepareForVideoHandoff` |
489
+ | Audio silent after long video session | FGS stopped while backgrounded; use 0.12.0+ retain and in-place resume on exit; optional prewarm before long video |
433
490
  | JS stuck in PAUSED after video (iOS, index > 0) | Missing `play()` after resume, or PLAYING event suppressed — fixed in 0.8.11 |
434
491
  | `PLAYBACK_POSITION` flood after background | Expected — position events suppressed while WebView backgrounded (0.9.1+) |
435
492
 
@@ -958,15 +1015,16 @@ Includes the new track, its index, and the state of the playlist.
958
1015
 
959
1016
  An audio track for playback by the playlist.
960
1017
 
961
- | Prop | Type | Description |
962
- | -------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
963
- | **`isStream`** | <code>boolean</code> | This item is a streaming asset. Make sure this is set to true for stream URLs, otherwise you will get odd behavior when the asset is paused. |
964
- | **`trackId`** | <code>string</code> | trackId is optional and if not passed in, an auto-generated UUID will be used. |
965
- | **`assetUrl`** | <code>string</code> | URL of the asset; can be local, a URL, or a streaming URL. If the asset is a stream, make sure that isStream is set to true, otherwise the plugin can't properly handle the item's buffer. |
966
- | **`albumArt`** | <code>string</code> | The local or remote URL to an image asset to be shown for this track. If this is null, the plugin's default image is used. |
967
- | **`artist`** | <code>string</code> | The track's artist |
968
- | **`album`** | <code>string</code> | Album the track belongs to |
969
- | **`title`** | <code>string</code> | Title of the track |
1018
+ | Prop | Type | Description |
1019
+ | ------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1020
+ | **`isStream`** | <code>boolean</code> | This item is a streaming asset. Make sure this is set to true for stream URLs, otherwise you will get odd behavior when the asset is paused. |
1021
+ | **`trackId`** | <code>string</code> | trackId is optional and if not passed in, an auto-generated UUID will be used. |
1022
+ | **`assetUrl`** | <code>string</code> | URL of the asset; can be local, a URL, or a streaming URL. If the asset is a stream, make sure that isStream is set to true, otherwise the plugin can't properly handle the item's buffer. |
1023
+ | **`albumArt`** | <code>string</code> | The local or remote URL to an image asset to be shown for this track. If this is null, the plugin's default image is used. |
1024
+ | **`artist`** | <code>string</code> | The track's artist |
1025
+ | **`album`** | <code>string</code> | Album the track belongs to |
1026
+ | **`title`** | <code>string</code> | Title of the track |
1027
+ | **`startPosition`** | <code>number</code> | Last known playback position to resume from, in seconds, when this track becomes current via a native skip-to-next/previous (e.g. OS notification, headset button, Android Auto/CarPlay, or the in-app Next/Previous buttons). Optional; defaults to 0. |
970
1028
 
971
1029
 
972
1030
  #### OnStatusErrorCallbackData
@@ -999,61 +1057,10 @@ Options governing the overall behavior of the audio player plugin
999
1057
 
1000
1058
  #### PlaylistOptions
1001
1059
 
1002
- | Prop | Type |
1003
- | ------------- | ----------------------------------------------------------------------------------- |
1004
- | **`items`** | <code><a href="#array">Array</a>&lt;<a href="#audiotrack">AudioTrack</a>&gt;</code> |
1005
- | **`options`** | <code><a href="#playlistitemoptions">PlaylistItemOptions</a></code> |
1006
-
1007
-
1008
- #### Array
1009
-
1010
- | Prop | Type | Description |
1011
- | ------------ | ------------------- | ------------------------------------------------------------------------------------------------------ |
1012
- | **`length`** | <code>number</code> | Gets or sets the length of the array. This is a number one higher than the highest index in the array. |
1013
-
1014
- | Method | Signature | Description |
1015
- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1016
- | **toString** | () =&gt; string | Returns a string representation of an array. |
1017
- | **toLocaleString** | () =&gt; string | Returns a string representation of an array. The elements are converted to string using their toLocalString methods. |
1018
- | **pop** | () =&gt; T \| undefined | Removes the last element from an array and returns it. If the array is empty, undefined is returned and the array is not modified. |
1019
- | **push** | (...items: T[]) =&gt; number | Appends new elements to the end of an array, and returns the new length of the array. |
1020
- | **concat** | (...items: <a href="#concatarray">ConcatArray</a>&lt;T&gt;[]) =&gt; T[] | Combines two or more arrays. This method returns a new array without modifying any existing arrays. |
1021
- | **concat** | (...items: (T \| <a href="#concatarray">ConcatArray</a>&lt;T&gt;)[]) =&gt; T[] | Combines two or more arrays. This method returns a new array without modifying any existing arrays. |
1022
- | **join** | (separator?: string \| undefined) =&gt; string | Adds all the elements of an array into a string, separated by the specified separator string. |
1023
- | **reverse** | () =&gt; T[] | Reverses the elements in an array in place. This method mutates the array and returns a reference to the same array. |
1024
- | **shift** | () =&gt; T \| undefined | Removes the first element from an array and returns it. If the array is empty, undefined is returned and the array is not modified. |
1025
- | **slice** | (start?: number \| undefined, end?: number \| undefined) =&gt; T[] | Returns a copy of a section of an array. For both start and end, a negative index can be used to indicate an offset from the end of the array. For example, -2 refers to the second to last element of the array. |
1026
- | **sort** | (compareFn?: ((a: T, b: T) =&gt; number) \| undefined) =&gt; this | Sorts an array in place. This method mutates the array and returns a reference to the same array. |
1027
- | **splice** | (start: number, deleteCount?: number \| undefined) =&gt; T[] | Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. |
1028
- | **splice** | (start: number, deleteCount: number, ...items: T[]) =&gt; T[] | Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. |
1029
- | **unshift** | (...items: T[]) =&gt; number | Inserts new elements at the start of an array, and returns the new length of the array. |
1030
- | **indexOf** | (searchElement: T, fromIndex?: number \| undefined) =&gt; number | Returns the index of the first occurrence of a value in an array, or -1 if it is not present. |
1031
- | **lastIndexOf** | (searchElement: T, fromIndex?: number \| undefined) =&gt; number | Returns the index of the last occurrence of a specified value in an array, or -1 if it is not present. |
1032
- | **every** | &lt;S extends T&gt;(predicate: (value: T, index: number, array: T[]) =&gt; value is S, thisArg?: any) =&gt; this is S[] | Determines whether all the members of an array satisfy the specified test. |
1033
- | **every** | (predicate: (value: T, index: number, array: T[]) =&gt; unknown, thisArg?: any) =&gt; boolean | Determines whether all the members of an array satisfy the specified test. |
1034
- | **some** | (predicate: (value: T, index: number, array: T[]) =&gt; unknown, thisArg?: any) =&gt; boolean | Determines whether the specified callback function returns true for any element of an array. |
1035
- | **forEach** | (callbackfn: (value: T, index: number, array: T[]) =&gt; void, thisArg?: any) =&gt; void | Performs the specified action for each element in an array. |
1036
- | **map** | &lt;U&gt;(callbackfn: (value: T, index: number, array: T[]) =&gt; U, thisArg?: any) =&gt; U[] | Calls a defined callback function on each element of an array, and returns an array that contains the results. |
1037
- | **filter** | &lt;S extends T&gt;(predicate: (value: T, index: number, array: T[]) =&gt; value is S, thisArg?: any) =&gt; S[] | Returns the elements of an array that meet the condition specified in a callback function. |
1038
- | **filter** | (predicate: (value: T, index: number, array: T[]) =&gt; unknown, thisArg?: any) =&gt; T[] | Returns the elements of an array that meet the condition specified in a callback function. |
1039
- | **reduce** | (callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) =&gt; T) =&gt; T | Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. |
1040
- | **reduce** | (callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) =&gt; T, initialValue: T) =&gt; T | |
1041
- | **reduce** | &lt;U&gt;(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) =&gt; U, initialValue: U) =&gt; U | Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. |
1042
- | **reduceRight** | (callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) =&gt; T) =&gt; T | Calls the specified callback function for all the elements in an array, in descending order. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. |
1043
- | **reduceRight** | (callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) =&gt; T, initialValue: T) =&gt; T | |
1044
- | **reduceRight** | &lt;U&gt;(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) =&gt; U, initialValue: U) =&gt; U | Calls the specified callback function for all the elements in an array, in descending order. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. |
1045
-
1046
-
1047
- #### ConcatArray
1048
-
1049
- | Prop | Type |
1050
- | ------------ | ------------------- |
1051
- | **`length`** | <code>number</code> |
1052
-
1053
- | Method | Signature |
1054
- | --------- | ------------------------------------------------------------------ |
1055
- | **join** | (separator?: string \| undefined) =&gt; string |
1056
- | **slice** | (start?: number \| undefined, end?: number \| undefined) =&gt; T[] |
1060
+ | Prop | Type |
1061
+ | ------------- | ------------------------------------------------------------------- |
1062
+ | **`items`** | <code>AudioTrack[]</code> |
1063
+ | **`options`** | <code><a href="#playlistitemoptions">PlaylistItemOptions</a></code> |
1057
1064
 
1058
1065
 
1059
1066
  #### PlaylistItemOptions
@@ -1097,9 +1104,9 @@ that were in the previous list.
1097
1104
 
1098
1105
  #### AddAllItemOptions
1099
1106
 
1100
- | Prop | Type |
1101
- | ----------- | ----------------------------------------------------------------------------------- |
1102
- | **`items`** | <code><a href="#array">Array</a>&lt;<a href="#audiotrack">AudioTrack</a>&gt;</code> |
1107
+ | Prop | Type |
1108
+ | ----------- | ------------------------- |
1109
+ | **`items`** | <code>AudioTrack[]</code> |
1103
1110
 
1104
1111
 
1105
1112
  #### RemoveItemOptions
@@ -1112,16 +1119,16 @@ that were in the previous list.
1112
1119
 
1113
1120
  #### RemoveItemsOptions
1114
1121
 
1115
- | Prop | Type |
1116
- | ----------- | ------------------------------------------------------------------------------------------------- |
1117
- | **`items`** | <code><a href="#array">Array</a>&lt;<a href="#removeitemoptions">RemoveItemOptions</a>&gt;</code> |
1122
+ | Prop | Type |
1123
+ | ----------- | -------------------------------- |
1124
+ | **`items`** | <code>RemoveItemOptions[]</code> |
1118
1125
 
1119
1126
 
1120
1127
  #### GetPlaylistResult
1121
1128
 
1122
- | Prop | Type |
1123
- | ----------- | ----------------------------------------------------------------------------------- |
1124
- | **`items`** | <code><a href="#array">Array</a>&lt;<a href="#audiotrack">AudioTrack</a>&gt;</code> |
1129
+ | Prop | Type |
1130
+ | ----------- | ------------------------- |
1131
+ | **`items`** | <code>AudioTrack[]</code> |
1125
1132
 
1126
1133
 
1127
1134
  #### SeekToOptions
@@ -1186,9 +1193,9 @@ that were in the previous list.
1186
1193
 
1187
1194
  #### ResumeAfterVideoHandoffResult
1188
1195
 
1189
- | Prop | Type | Description |
1190
- | ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1191
- | **`resumed`** | <code>boolean</code> | `true` when native already handled seek (and play when requested) in place. When `true`, JS should skip redundant `seekTo` / `play` to avoid a stutter. `false` on web, prewarm, paused Android handoff, and Android last-resort `beginPlayback`. |
1196
+ | Prop | Type | Description |
1197
+ | ------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1198
+ | **`resumed`** | <code>boolean</code> | `true` when native already handled seek (and play when requested) in place. When `true`, JS should skip redundant `seekTo` / `play` to avoid a stutter. `false` on web, prewarm, and paused Android handoff (native does not auto-play). |
1192
1199
 
1193
1200
 
1194
1201
  #### ResumeAfterVideoHandoffOptions
@@ -1286,8 +1293,7 @@ Inspired by:
1286
1293
 
1287
1294
  - [cordova-plugin-playlist](https://github.com/Rolamix/cordova-plugin-playlist)
1288
1295
  - [cordova-plugin-media](https://github.com/apache/cordova-plugin-media)
1289
- - [ExoMedia](https://github.com/brianwernick/ExoMedia)
1290
- - [PlaylistCore](https://github.com/brianwernick/PlaylistCore)
1296
+ - [ExoMedia](https://github.com/brianwernick/ExoMedia) and [PlaylistCore](https://github.com/brianwernick/PlaylistCore) — pre-0.12.0 Android stack only; replaced by Media3 in 0.12.0
1291
1297
  - [Bi-Directional AVQueuePlayer](https://github.com/jrtaal/AVBidirectionalQueuePlayer)
1292
1298
  - [cordova-music-controls-plugin](https://github.com/homerours/cordova-music-controls-plugin)
1293
1299
 
@@ -8,8 +8,8 @@ buildscript {
8
8
  mavenCentral()
9
9
  }
10
10
  dependencies {
11
- classpath 'com.android.tools.build:gradle:8.13.0'
12
- classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0'
11
+ classpath 'com.android.tools.build:gradle:9.4.1'
12
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20'
13
13
  }
14
14
  }
15
15
 
@@ -21,13 +21,13 @@ ext {
21
21
  androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
22
22
  // Only used when this module is built standalone (e.g. `npm run verify:android`); when
23
23
  // embedded, the consuming app's root build.gradle already sets rootProject.ext.kotlin_version.
24
- kotlin_version = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '2.1.0'
24
+ kotlin_version = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '2.4.20'
25
25
  }
26
26
 
27
27
  apply plugin: 'com.android.library'
28
28
 
29
- // AGP 9+ (consuming apps) provides built-in Kotlin; applying kotlin-android there fails.
30
- // Standalone `./gradlew test` (CI) still uses AGP 8.x from this module's buildscript.
29
+ // AGP 9+ provides built-in Kotlin; applying kotlin-android there fails. Hosts still on AGP 8
30
+ // get the Kotlin plugin applied here.
31
31
  def agpMajorVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
32
32
  if (agpMajorVersion < 9) {
33
33
  apply plugin: 'kotlin-android'
@@ -49,7 +49,7 @@ android {
49
49
  proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
50
50
  }
51
51
  }
52
- lintOptions {
52
+ lint {
53
53
  abortOnError = false
54
54
  }
55
55
  compileOptions {
@@ -58,6 +58,10 @@ android {
58
58
  }
59
59
  }
60
60
 
61
+ tasks.withType(JavaCompile).configureEach {
62
+ options.compilerArgs << '-Xlint:deprecation'
63
+ }
64
+
61
65
  if (agpMajorVersion < 9) {
62
66
  kotlin {
63
67
  compilerOptions {
@@ -76,23 +80,24 @@ dependencies {
76
80
  implementation fileTree(dir: 'libs', include: ['*.jar'])
77
81
  implementation project(':capacitor-android')
78
82
  testImplementation "junit:junit:$junitVersion"
83
+ testImplementation "org.robolectric:robolectric:4.17"
79
84
  // Real org.json implementation for plain JVM unit tests: the android.jar stub used for
80
85
  // compilation throws "Stub!" for org.json.* at runtime outside instrumented/Robolectric tests.
81
- testImplementation "org.json:json:20231013"
86
+ testImplementation "org.json:json:20260814"
82
87
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
83
88
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
84
89
  implementation "androidx.core:core-ktx:1.17.0"
85
90
  implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlin_version}"
86
91
 
87
- // Image Exomedia
88
- implementation 'com.devbrackets.android:exomedia:5.2.0'
92
+ def media3Version = '1.11.1'
93
+ implementation "androidx.media3:media3-exoplayer:$media3Version"
94
+ implementation "androidx.media3:media3-exoplayer-hls:$media3Version"
95
+ implementation "androidx.media3:media3-session:$media3Version"
89
96
 
90
- // Image Loading
91
- implementation 'com.github.bumptech.glide:glide:5.0.5'
92
- annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
97
+ // Image Loading (artwork — Story 55.5 wires Glide into Media3 notification)
98
+ implementation 'com.github.bumptech.glide:glide:5.0.7'
99
+ annotationProcessor 'com.github.bumptech.glide:compiler:5.0.7'
93
100
 
94
- // Playlist support
95
- implementation 'com.devbrackets.android:playlistcore:2.2.0'
96
- implementation 'androidx.work:work-runtime-ktx:2.11.0'
101
+ implementation 'androidx.work:work-runtime-ktx:2.11.2'
97
102
 
98
103
  }
@@ -1,6 +1,6 @@
1
1
  distributionBase=GRADLE_USER_HOME
2
2
  distributionPath=wrapper/dists
3
- distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
3
+ distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-all.zip
4
4
  networkTimeout=10000
5
5
  validateDistributionUrl=true
6
6
  zipStoreBase=GRADLE_USER_HOME
@@ -0,0 +1,96 @@
1
+ package org.dwbn.plugins.playlist;
2
+
3
+ import android.net.Uri;
4
+ import androidx.annotation.OptIn;
5
+ import androidx.media3.common.MediaItem;
6
+ import androidx.media3.common.MediaMetadata;
7
+ import androidx.media3.common.MimeTypes;
8
+ import androidx.media3.common.util.UnstableApi;
9
+ import java.util.Locale;
10
+ import org.dwbn.plugins.playlist.data.AudioTrack;
11
+
12
+ /**
13
+ * Single factory for audio {@link MediaItem} instances. Story 55.4 — no DRM; later epics attach
14
+ * {@link MediaItem.DrmConfiguration} here only.
15
+ */
16
+ @OptIn(markerClass = UnstableApi.class)
17
+ public final class AudioMediaItemFactory {
18
+
19
+ private AudioMediaItemFactory() {}
20
+
21
+ public static MediaItem fromAudioTrack(AudioTrack track) {
22
+ return fromUrl(track.getMediaUrl(), track.isStream(), track);
23
+ }
24
+
25
+ public static MediaItem fromUrl(String url, boolean isStream) {
26
+ return fromUrl(url, isStream, null);
27
+ }
28
+
29
+ private static MediaItem fromUrl(String url, boolean isStream, AudioTrack track) {
30
+ Uri uri = (url == null || url.isEmpty()) ? Uri.EMPTY : Uri.parse(url);
31
+ MediaItem.Builder builder = new MediaItem.Builder().setUri(uri);
32
+ if (track != null && track.getTrackId() != null) {
33
+ builder.setMediaId(track.getTrackId());
34
+ }
35
+ if (shouldUseHlsMimeType(url, isStream)) {
36
+ builder.setMimeType(MimeTypes.APPLICATION_M3U8);
37
+ }
38
+ if (track != null) {
39
+ MediaMetadata.Builder metadata = new MediaMetadata.Builder();
40
+ String title = track.getTitle();
41
+ if (title == null || title.isEmpty()) {
42
+ metadata.setTitle("Audio playback");
43
+ } else {
44
+ metadata.setTitle(title);
45
+ }
46
+ String artist = track.getArtist();
47
+ if (artist != null && !artist.isEmpty()) {
48
+ metadata.setArtist(artist);
49
+ }
50
+ String album = track.getAlbum();
51
+ if (album != null && !album.isEmpty()) {
52
+ metadata.setAlbumTitle(album);
53
+ }
54
+ String artwork = artworkUriString(track);
55
+ if (artwork != null) {
56
+ metadata.setArtworkUri(Uri.parse(artwork));
57
+ }
58
+ builder.setMediaMetadata(metadata.build());
59
+ }
60
+ return builder.build();
61
+ }
62
+
63
+ /** Artwork URI string passed to {@link MediaMetadata.Builder#setArtworkUri}. */
64
+ static String artworkUriString(AudioTrack track) {
65
+ if (track == null) {
66
+ return null;
67
+ }
68
+ String artwork = track.getThumbnailUrl();
69
+ if (artwork == null || artwork.isEmpty()) {
70
+ return null;
71
+ }
72
+ return artwork;
73
+ }
74
+
75
+ public static boolean shouldUseHlsMimeType(String url, boolean isStream) {
76
+ if (url == null || url.isEmpty()) {
77
+ return false;
78
+ }
79
+ String lower = url.toLowerCase(Locale.US);
80
+ if (lower.contains(".m3u8")) {
81
+ return true;
82
+ }
83
+ return isStream && !hasKnownMediaExtension(lower);
84
+ }
85
+
86
+ private static boolean hasKnownMediaExtension(String lowerUrl) {
87
+ return lowerUrl.contains(".mp3")
88
+ || lowerUrl.contains(".mp4")
89
+ || lowerUrl.contains(".m4a")
90
+ || lowerUrl.contains(".aac")
91
+ || lowerUrl.contains(".ogg")
92
+ || lowerUrl.contains(".wav")
93
+ || lowerUrl.contains(".flac")
94
+ || lowerUrl.contains(".webm");
95
+ }
96
+ }
@@ -3,10 +3,10 @@ package org.dwbn.plugins.playlist
3
3
  import android.os.Handler
4
4
  import android.os.Looper
5
5
  import android.util.Log
6
- import com.devbrackets.android.playlistcore.data.MediaProgress
7
6
  import com.getcapacitor.*
8
7
  import com.getcapacitor.annotation.CapacitorPlugin
9
8
  import org.dwbn.plugins.playlist.data.AudioTrack
9
+ import org.dwbn.plugins.playlist.manager.PlaybackProgress
10
10
  import org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler
11
11
  import org.dwbn.plugins.playlist.service.MediaService
12
12
  import org.json.JSONArray
@@ -273,7 +273,7 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
273
273
  val serviceForeground = MediaService.instance?.isRunningInForeground() == true
274
274
  if (handler == null || handler.currentMediaPlayer == null) {
275
275
  val posMs = (audioPlayerImpl!!.getLastKnownPositionSec() * 1000f).toLong()
276
- if (serviceForeground && handler is AudioPlaylistHandler<*, *>) {
276
+ if (serviceForeground && handler is AudioPlaylistHandler) {
277
277
  handler.startItemPlayback(posMs, false)
278
278
  Log.i(TAG, "play: re-armed via startItemPlayback at ${posMs}ms (FGS already foreground)")
279
279
  } else {
@@ -407,7 +407,7 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
407
407
  fun seekTo(call: PluginCall) {
408
408
  Handler(Looper.getMainLooper()).post {
409
409
  var position: Long = 0
410
- val progress: MediaProgress? = audioPlayerImpl!!.playlistManager.currentProgress
410
+ val progress: PlaybackProgress? = audioPlayerImpl!!.playlistManager.getCurrentProgress()
411
411
  if (progress != null) {
412
412
  position = progress.position
413
413
  }