@capgo/capacitor-native-audio 8.4.3
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/CapgoCapacitorNativeAudio.podspec +16 -0
- package/LICENSE +373 -0
- package/Package.swift +31 -0
- package/README.md +1229 -0
- package/android/build.gradle +89 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ee/forgr/audio/AudioAsset.java +611 -0
- package/android/src/main/java/ee/forgr/audio/AudioCompletionListener.java +5 -0
- package/android/src/main/java/ee/forgr/audio/AudioDispatcher.java +208 -0
- package/android/src/main/java/ee/forgr/audio/Constant.java +36 -0
- package/android/src/main/java/ee/forgr/audio/HlsAvailabilityChecker.java +84 -0
- package/android/src/main/java/ee/forgr/audio/Logger.java +55 -0
- package/android/src/main/java/ee/forgr/audio/NativeAudio.java +2022 -0
- package/android/src/main/java/ee/forgr/audio/RemoteAudioAsset.java +886 -0
- package/android/src/main/java/ee/forgr/audio/StreamAudioAsset.java +708 -0
- package/android/src/main/res/values/colors.xml +3 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +3 -0
- package/dist/docs.json +1470 -0
- package/dist/esm/audio-asset.d.ts +4 -0
- package/dist/esm/audio-asset.js +6 -0
- package/dist/esm/audio-asset.js.map +1 -0
- package/dist/esm/definitions.d.ts +597 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +82 -0
- package/dist/esm/web.js +553 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +571 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +574 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/NativeAudioPlugin/AudioAsset+Fade.swift +157 -0
- package/ios/Sources/NativeAudioPlugin/AudioAsset.swift +403 -0
- package/ios/Sources/NativeAudioPlugin/Constant.swift +52 -0
- package/ios/Sources/NativeAudioPlugin/Logger.swift +43 -0
- package/ios/Sources/NativeAudioPlugin/Plugin.swift +1786 -0
- package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset+Fade.swift +152 -0
- package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset.swift +405 -0
- package/ios/Tests/NativeAudioPluginTests/PluginTests.swift +648 -0
- package/ios/Tests/README.md +39 -0
- package/package.json +101 -0
- package/scripts/configure-dependencies.js +251 -0
|
@@ -0,0 +1,2022 @@
|
|
|
1
|
+
package ee.forgr.audio;
|
|
2
|
+
|
|
3
|
+
import static ee.forgr.audio.Constant.ASSET_ID;
|
|
4
|
+
import static ee.forgr.audio.Constant.ASSET_PATH;
|
|
5
|
+
import static ee.forgr.audio.Constant.AUDIO_CHANNEL_NUM;
|
|
6
|
+
import static ee.forgr.audio.Constant.DELAY;
|
|
7
|
+
import static ee.forgr.audio.Constant.DURATION;
|
|
8
|
+
import static ee.forgr.audio.Constant.ERROR_ASSET_NOT_LOADED;
|
|
9
|
+
import static ee.forgr.audio.Constant.ERROR_ASSET_PATH_MISSING;
|
|
10
|
+
import static ee.forgr.audio.Constant.ERROR_AUDIO_ASSET_MISSING;
|
|
11
|
+
import static ee.forgr.audio.Constant.ERROR_AUDIO_EXISTS;
|
|
12
|
+
import static ee.forgr.audio.Constant.ERROR_AUDIO_ID_MISSING;
|
|
13
|
+
import static ee.forgr.audio.Constant.FADE_IN;
|
|
14
|
+
import static ee.forgr.audio.Constant.FADE_IN_DURATION;
|
|
15
|
+
import static ee.forgr.audio.Constant.FADE_OUT;
|
|
16
|
+
import static ee.forgr.audio.Constant.FADE_OUT_DURATION;
|
|
17
|
+
import static ee.forgr.audio.Constant.FADE_OUT_START_TIME;
|
|
18
|
+
import static ee.forgr.audio.Constant.LOOP;
|
|
19
|
+
import static ee.forgr.audio.Constant.NOTIFICATION_METADATA;
|
|
20
|
+
import static ee.forgr.audio.Constant.OPT_FOCUS_AUDIO;
|
|
21
|
+
import static ee.forgr.audio.Constant.PLAY;
|
|
22
|
+
import static ee.forgr.audio.Constant.RATE;
|
|
23
|
+
import static ee.forgr.audio.Constant.SHOW_NOTIFICATION;
|
|
24
|
+
import static ee.forgr.audio.Constant.TIME;
|
|
25
|
+
import static ee.forgr.audio.Constant.VOLUME;
|
|
26
|
+
|
|
27
|
+
import android.Manifest;
|
|
28
|
+
import android.app.NotificationChannel;
|
|
29
|
+
import android.app.NotificationManager;
|
|
30
|
+
import android.app.PendingIntent;
|
|
31
|
+
import android.content.Context;
|
|
32
|
+
import android.content.Intent;
|
|
33
|
+
import android.content.res.AssetFileDescriptor;
|
|
34
|
+
import android.content.res.AssetManager;
|
|
35
|
+
import android.graphics.Bitmap;
|
|
36
|
+
import android.graphics.BitmapFactory;
|
|
37
|
+
import android.media.AudioManager;
|
|
38
|
+
import android.net.Uri;
|
|
39
|
+
import android.os.Build;
|
|
40
|
+
import android.os.Handler;
|
|
41
|
+
import android.os.Looper;
|
|
42
|
+
import android.os.ParcelFileDescriptor;
|
|
43
|
+
import android.os.SystemClock;
|
|
44
|
+
import android.support.v4.media.MediaMetadataCompat;
|
|
45
|
+
import android.support.v4.media.session.MediaSessionCompat;
|
|
46
|
+
import android.support.v4.media.session.PlaybackStateCompat;
|
|
47
|
+
import android.util.Log;
|
|
48
|
+
import androidx.core.app.NotificationCompat;
|
|
49
|
+
import androidx.core.app.NotificationManagerCompat;
|
|
50
|
+
import androidx.media3.common.util.UnstableApi;
|
|
51
|
+
import com.getcapacitor.JSObject;
|
|
52
|
+
import com.getcapacitor.Plugin;
|
|
53
|
+
import com.getcapacitor.PluginCall;
|
|
54
|
+
import com.getcapacitor.PluginMethod;
|
|
55
|
+
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
56
|
+
import com.getcapacitor.annotation.Permission;
|
|
57
|
+
import java.io.File;
|
|
58
|
+
import java.io.FileDescriptor;
|
|
59
|
+
import java.io.FileInputStream;
|
|
60
|
+
import java.io.IOException;
|
|
61
|
+
import java.net.URI;
|
|
62
|
+
import java.net.URL;
|
|
63
|
+
import java.util.ArrayList;
|
|
64
|
+
import java.util.HashMap;
|
|
65
|
+
import java.util.HashSet;
|
|
66
|
+
import java.util.Iterator;
|
|
67
|
+
import java.util.Map;
|
|
68
|
+
import java.util.Set;
|
|
69
|
+
import java.util.UUID;
|
|
70
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
71
|
+
import java.util.concurrent.CopyOnWriteArrayList;
|
|
72
|
+
|
|
73
|
+
@UnstableApi
|
|
74
|
+
@CapacitorPlugin(
|
|
75
|
+
name = "NativeAudio",
|
|
76
|
+
permissions = {
|
|
77
|
+
@Permission(strings = { Manifest.permission.MODIFY_AUDIO_SETTINGS }),
|
|
78
|
+
@Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }),
|
|
79
|
+
@Permission(strings = { Manifest.permission.READ_PHONE_STATE })
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
public class NativeAudio extends Plugin implements AudioManager.OnAudioFocusChangeListener {
|
|
83
|
+
|
|
84
|
+
private final String pluginVersion = "";
|
|
85
|
+
|
|
86
|
+
public static final String TAG = "NativeAudio";
|
|
87
|
+
private static final Logger logger = new Logger(TAG);
|
|
88
|
+
public static boolean debugEnabled = false;
|
|
89
|
+
|
|
90
|
+
private static ConcurrentHashMap<String, AudioAsset> audioAssetList = new ConcurrentHashMap<>();
|
|
91
|
+
private static CopyOnWriteArrayList<AudioAsset> resumeList;
|
|
92
|
+
private AudioManager audioManager;
|
|
93
|
+
private boolean audioFocusRequested = false;
|
|
94
|
+
private int originalAudioMode = AudioManager.MODE_INVALID;
|
|
95
|
+
|
|
96
|
+
private final Map<String, PluginCall> pendingDurationCalls = new ConcurrentHashMap<>();
|
|
97
|
+
private final Map<String, Handler> pendingPlayHandlers = new ConcurrentHashMap<>();
|
|
98
|
+
private final Map<String, Runnable> pendingPlayRunnables = new ConcurrentHashMap<>();
|
|
99
|
+
private final Map<String, JSObject> audioData = new ConcurrentHashMap<>();
|
|
100
|
+
private final Map<String, Integer> playbackStateByAssetId = new ConcurrentHashMap<>();
|
|
101
|
+
|
|
102
|
+
// Notification center support
|
|
103
|
+
private boolean showNotification = false;
|
|
104
|
+
private Map<String, Map<String, String>> notificationMetadataMap = new ConcurrentHashMap<>();
|
|
105
|
+
private MediaSessionCompat mediaSession;
|
|
106
|
+
private String currentlyPlayingAssetId;
|
|
107
|
+
private static final int NOTIFICATION_ID = 1001;
|
|
108
|
+
private static final String CHANNEL_ID = "native_audio_channel";
|
|
109
|
+
private static final int MAX_NOTIFICATION_ARTWORK_SIZE = 512;
|
|
110
|
+
private static final double NOTIFICATION_SKIP_SECONDS = 15.0;
|
|
111
|
+
|
|
112
|
+
// Track playOnce assets for automatic cleanup
|
|
113
|
+
private Set<String> playOnceAssets = new HashSet<>();
|
|
114
|
+
|
|
115
|
+
// Background playback support
|
|
116
|
+
private boolean backgroundPlayback = false;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Initializes plugin runtime state by obtaining the system {@link AudioManager}, preparing the asset map,
|
|
120
|
+
* and recording the device's original audio mode without requesting audio focus.
|
|
121
|
+
*/
|
|
122
|
+
@Override
|
|
123
|
+
public void load() {
|
|
124
|
+
super.load();
|
|
125
|
+
|
|
126
|
+
this.audioManager = (AudioManager) this.getActivity().getSystemService(Context.AUDIO_SERVICE);
|
|
127
|
+
|
|
128
|
+
audioAssetList = new ConcurrentHashMap<>();
|
|
129
|
+
|
|
130
|
+
// Store the original audio mode but don't request focus yet
|
|
131
|
+
if (this.audioManager != null) {
|
|
132
|
+
originalAudioMode = this.audioManager.getMode();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@Override
|
|
137
|
+
public void onAudioFocusChange(int focusChange) {
|
|
138
|
+
try {
|
|
139
|
+
if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
|
|
140
|
+
// Pause playback - temporary loss
|
|
141
|
+
for (AudioAsset audio : audioAssetList.values()) {
|
|
142
|
+
if (audio.isPlaying()) {
|
|
143
|
+
audio.pause();
|
|
144
|
+
updateTrackedPlaybackState(audio.getAssetId(), PlaybackStateCompat.STATE_PAUSED);
|
|
145
|
+
resumeList.add(audio);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
syncCurrentPlaybackState("audioFocusLossTransient");
|
|
149
|
+
} else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
|
|
150
|
+
// Resume playback
|
|
151
|
+
if (resumeList != null) {
|
|
152
|
+
while (!resumeList.isEmpty()) {
|
|
153
|
+
AudioAsset audio = resumeList.remove(0);
|
|
154
|
+
audio.resume();
|
|
155
|
+
updateTrackedPlaybackState(audio.getAssetId(), PlaybackStateCompat.STATE_PLAYING);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
syncCurrentPlaybackState("audioFocusGain");
|
|
159
|
+
} else if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
|
|
160
|
+
// Stop playback - permanent loss
|
|
161
|
+
String stoppedAssetId = currentlyPlayingAssetId;
|
|
162
|
+
for (AudioAsset audio : audioAssetList.values()) {
|
|
163
|
+
audio.stop();
|
|
164
|
+
updateTrackedPlaybackState(audio.getAssetId(), PlaybackStateCompat.STATE_STOPPED);
|
|
165
|
+
}
|
|
166
|
+
audioManager.abandonAudioFocus(this);
|
|
167
|
+
if (isStringValid(stoppedAssetId)) {
|
|
168
|
+
clearNotification();
|
|
169
|
+
currentlyPlayingAssetId = null;
|
|
170
|
+
notifyPlaybackState(stoppedAssetId, "audioFocusLoss");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (Exception ex) {
|
|
174
|
+
Log.e(TAG, "Error handling audio focus change", ex);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@Override
|
|
179
|
+
protected void handleOnPause() {
|
|
180
|
+
super.handleOnPause();
|
|
181
|
+
|
|
182
|
+
// Skip automatic pause when background playback is enabled
|
|
183
|
+
if (backgroundPlayback) {
|
|
184
|
+
Log.d(TAG, "Background playback enabled - skipping automatic pause");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
if (audioAssetList != null) {
|
|
190
|
+
for (Map.Entry<String, AudioAsset> entry : audioAssetList.entrySet()) {
|
|
191
|
+
AudioAsset audio = entry.getValue();
|
|
192
|
+
|
|
193
|
+
if (audio != null) {
|
|
194
|
+
boolean wasPlaying = audio.pause();
|
|
195
|
+
|
|
196
|
+
if (wasPlaying) {
|
|
197
|
+
updateTrackedPlaybackState(entry.getKey(), PlaybackStateCompat.STATE_PAUSED);
|
|
198
|
+
resumeList.add(audio);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
syncCurrentPlaybackState("appPause");
|
|
204
|
+
} catch (Exception ex) {
|
|
205
|
+
Log.d(TAG, "Exception caught while listening for handleOnPause: " + ex.getLocalizedMessage());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Override
|
|
210
|
+
protected void handleOnResume() {
|
|
211
|
+
super.handleOnResume();
|
|
212
|
+
|
|
213
|
+
// Skip automatic resume when background playback is enabled
|
|
214
|
+
if (backgroundPlayback) {
|
|
215
|
+
Log.d(TAG, "Background playback enabled - skipping automatic resume");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (resumeList != null) {
|
|
221
|
+
while (!resumeList.isEmpty()) {
|
|
222
|
+
AudioAsset audio = resumeList.remove(0);
|
|
223
|
+
|
|
224
|
+
if (audio != null) {
|
|
225
|
+
audio.resume();
|
|
226
|
+
updateTrackedPlaybackState(audio.getAssetId(), PlaybackStateCompat.STATE_PLAYING);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
syncCurrentPlaybackState("appResume");
|
|
231
|
+
} catch (Exception ex) {
|
|
232
|
+
Log.d(TAG, "Exception caught while listening for handleOnResume: " + ex.getLocalizedMessage());
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@PluginMethod
|
|
237
|
+
public void setDebugMode(PluginCall call) {
|
|
238
|
+
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
|
|
239
|
+
debugEnabled = enabled;
|
|
240
|
+
if (enabled) {
|
|
241
|
+
logger.info("Debug mode enabled");
|
|
242
|
+
}
|
|
243
|
+
call.resolve();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@PluginMethod
|
|
247
|
+
public void configure(PluginCall call) {
|
|
248
|
+
initSoundPool();
|
|
249
|
+
|
|
250
|
+
if (this.audioManager == null) {
|
|
251
|
+
call.resolve();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Save original audio mode if not already saved
|
|
256
|
+
if (originalAudioMode == AudioManager.MODE_INVALID) {
|
|
257
|
+
originalAudioMode = this.audioManager.getMode();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
boolean focus = call.getBoolean(OPT_FOCUS_AUDIO, false);
|
|
261
|
+
boolean background = call.getBoolean("background", false);
|
|
262
|
+
this.showNotification = call.getBoolean(SHOW_NOTIFICATION, false);
|
|
263
|
+
this.backgroundPlayback = call.getBoolean("backgroundPlayback", false);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
if (focus) {
|
|
267
|
+
// Request audio focus for playback with ducking
|
|
268
|
+
int result = this.audioManager.requestAudioFocus(
|
|
269
|
+
this,
|
|
270
|
+
AudioManager.STREAM_MUSIC,
|
|
271
|
+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
|
|
272
|
+
); // Allow other audio to play quietly
|
|
273
|
+
audioFocusRequested = true;
|
|
274
|
+
} else if (audioFocusRequested) {
|
|
275
|
+
this.audioManager.abandonAudioFocus(this);
|
|
276
|
+
audioFocusRequested = false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (background) {
|
|
280
|
+
// Set playback to continue in background
|
|
281
|
+
this.audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
|
282
|
+
} else {
|
|
283
|
+
this.audioManager.setMode(AudioManager.MODE_NORMAL);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (this.showNotification) {
|
|
287
|
+
setupMediaSession();
|
|
288
|
+
createNotificationChannel();
|
|
289
|
+
}
|
|
290
|
+
} catch (Exception ex) {
|
|
291
|
+
Log.e(TAG, "Error configuring audio", ex);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
call.resolve();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@PluginMethod
|
|
298
|
+
public void isPreloaded(final PluginCall call) {
|
|
299
|
+
new Thread(
|
|
300
|
+
new Runnable() {
|
|
301
|
+
@Override
|
|
302
|
+
public void run() {
|
|
303
|
+
initSoundPool();
|
|
304
|
+
|
|
305
|
+
String audioId = call.getString(ASSET_ID);
|
|
306
|
+
|
|
307
|
+
if (!isStringValid(audioId)) {
|
|
308
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
call.resolve(new JSObject().put("found", audioAssetList.containsKey(audioId)));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
.start();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Initiates preloading of an audio asset described by the plugin call.
|
|
320
|
+
*
|
|
321
|
+
* @param call the PluginCall containing preload options (for example `assetId`, `assetPath`, `isUrl`, `isComplex`, headers, and optional notification metadata); the call will be resolved or rejected when the preload operation completes.
|
|
322
|
+
*/
|
|
323
|
+
@PluginMethod
|
|
324
|
+
public void preload(final PluginCall call) {
|
|
325
|
+
this.getActivity().runOnUiThread(
|
|
326
|
+
new Runnable() {
|
|
327
|
+
@Override
|
|
328
|
+
public void run() {
|
|
329
|
+
preloadAsset(call);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Play an audio asset a single time and automatically remove its resources when finished.
|
|
337
|
+
*
|
|
338
|
+
* <p>Preloads the specified asset, optionally starts playback immediately, and ensures the
|
|
339
|
+
* asset is unloaded and any associated notification metadata are cleared after completion or on
|
|
340
|
+
* error. Supports local file paths and remote URLs, HLS streams when available, custom HTTP
|
|
341
|
+
* headers for remote requests, and optional deletion of local source files after playback.
|
|
342
|
+
*
|
|
343
|
+
* @param call Capacitor PluginCall containing options:
|
|
344
|
+
* <ul>
|
|
345
|
+
* <li><code>assetPath</code> (required): path or URL to the audio file;</li>
|
|
346
|
+
* <li><code>volume</code> (optional): playback volume (0.1–1.0), default 1.0;</li>
|
|
347
|
+
* <li><code>isUrl</code> (optional): treat <code>assetPath</code> as a URL when true, default false;</li>
|
|
348
|
+
* <li><code>autoPlay</code> (optional): start playback immediately when true, default true;</li>
|
|
349
|
+
* <li><code>deleteAfterPlay</code> (optional): delete the local file after playback when true, default false;</li>
|
|
350
|
+
* <li><code>headers</code> (optional): JS object of HTTP headers for remote requests;</li>
|
|
351
|
+
* <li><code>notificationMetadata</code> (optional): object with <code>title</code>, <code>artist</code>,
|
|
352
|
+
* <code>album</code>, <code>artworkUrl</code> for notification display.</li>
|
|
353
|
+
* </ul>
|
|
354
|
+
*/
|
|
355
|
+
@PluginMethod
|
|
356
|
+
public void playOnce(final PluginCall call) {
|
|
357
|
+
this.getActivity().runOnUiThread(
|
|
358
|
+
new Runnable() {
|
|
359
|
+
/**
|
|
360
|
+
* Preloads a temporary audio asset, optionally plays it one time, and schedules automatic cleanup when playback completes.
|
|
361
|
+
*
|
|
362
|
+
* <p>The method generates a unique temporary assetId, validates options (path, volume, local/remote, headers),
|
|
363
|
+
* loads the asset into the plugin's asset map, registers completion listeners to dispatch the completion event
|
|
364
|
+
* and to unload/remove notification metadata and tracking state, and optionally deletes the source file from
|
|
365
|
+
* safe application directories after playback. If configured, it also updates the media notification and returns
|
|
366
|
+
* the generated `assetId` to the caller.
|
|
367
|
+
*/
|
|
368
|
+
@Override
|
|
369
|
+
public void run() {
|
|
370
|
+
try {
|
|
371
|
+
NativeAudio.this.initSoundPool();
|
|
372
|
+
|
|
373
|
+
// Generate unique temporary asset ID
|
|
374
|
+
final String assetId =
|
|
375
|
+
"playOnce_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8);
|
|
376
|
+
|
|
377
|
+
// Extract options
|
|
378
|
+
String assetPath = call.getString("assetPath");
|
|
379
|
+
if (!NativeAudio.this.isStringValid(assetPath)) {
|
|
380
|
+
call.reject("Asset Path is missing - " + assetPath);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
boolean autoPlay = call.getBoolean("autoPlay", true);
|
|
385
|
+
final boolean deleteAfterPlay = call.getBoolean("deleteAfterPlay", false);
|
|
386
|
+
float volume = call.getFloat(VOLUME, 1F);
|
|
387
|
+
boolean isLocalUrl = call.getBoolean("isUrl", false);
|
|
388
|
+
int audioChannelNum = 1; // Single channel for playOnce
|
|
389
|
+
|
|
390
|
+
// Track this as a playOnce asset
|
|
391
|
+
NativeAudio.this.playOnceAssets.add(assetId);
|
|
392
|
+
|
|
393
|
+
// Store notification metadata if provided
|
|
394
|
+
JSObject metadata = call.getObject(NOTIFICATION_METADATA);
|
|
395
|
+
if (metadata != null) {
|
|
396
|
+
Map<String, String> metadataMap = new HashMap<>();
|
|
397
|
+
if (metadata.has("title")) metadataMap.put("title", metadata.getString("title"));
|
|
398
|
+
if (metadata.has("artist")) metadataMap.put("artist", metadata.getString("artist"));
|
|
399
|
+
if (metadata.has("album")) metadataMap.put("album", metadata.getString("album"));
|
|
400
|
+
if (metadata.has("artworkUrl")) metadataMap.put("artworkUrl", metadata.getString("artworkUrl"));
|
|
401
|
+
if (!metadataMap.isEmpty()) {
|
|
402
|
+
NativeAudio.this.notificationMetadataMap.put(assetId, metadataMap);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Preload the asset using the helper method
|
|
407
|
+
try {
|
|
408
|
+
// Check if asset already exists
|
|
409
|
+
if (NativeAudio.this.audioAssetList.containsKey(assetId)) {
|
|
410
|
+
call.reject(ERROR_AUDIO_EXISTS + " - " + assetId);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Load the asset using the helper method
|
|
414
|
+
JSObject headersObj = call.getObject("headers");
|
|
415
|
+
AudioAsset asset = NativeAudio.this.loadAudioAsset(
|
|
416
|
+
assetId,
|
|
417
|
+
assetPath,
|
|
418
|
+
isLocalUrl,
|
|
419
|
+
volume,
|
|
420
|
+
audioChannelNum,
|
|
421
|
+
headersObj
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Add to asset list; completion listener is set below with cleanup
|
|
425
|
+
NativeAudio.this.audioAssetList.put(assetId, asset);
|
|
426
|
+
|
|
427
|
+
// Store the file path if we need to delete it later
|
|
428
|
+
// Only delete local file:// URLs, not remote streaming URLs
|
|
429
|
+
final String filePathToDelete = (deleteAfterPlay && assetPath.startsWith("file://")) ? assetPath : null;
|
|
430
|
+
|
|
431
|
+
// Set up completion listener for automatic cleanup
|
|
432
|
+
asset.setCompletionListener(
|
|
433
|
+
new AudioCompletionListener() {
|
|
434
|
+
@Override
|
|
435
|
+
public void onCompletion(String completedAssetId) {
|
|
436
|
+
// Call the original completion dispatcher first
|
|
437
|
+
NativeAudio.this.dispatchComplete(completedAssetId);
|
|
438
|
+
|
|
439
|
+
// Then perform cleanup
|
|
440
|
+
NativeAudio.this.getActivity().runOnUiThread(() -> {
|
|
441
|
+
try {
|
|
442
|
+
// Unload the asset
|
|
443
|
+
AudioAsset assetToUnload = NativeAudio.this.audioAssetList.get(assetId);
|
|
444
|
+
if (assetToUnload != null) {
|
|
445
|
+
assetToUnload.unload();
|
|
446
|
+
NativeAudio.this.audioAssetList.remove(assetId);
|
|
447
|
+
}
|
|
448
|
+
NativeAudio.this.clearTrackedPlaybackState(assetId);
|
|
449
|
+
|
|
450
|
+
// Remove from tracking sets
|
|
451
|
+
NativeAudio.this.playOnceAssets.remove(assetId);
|
|
452
|
+
NativeAudio.this.notificationMetadataMap.remove(assetId);
|
|
453
|
+
|
|
454
|
+
// Clear notification if this was the currently playing asset
|
|
455
|
+
if (assetId.equals(NativeAudio.this.currentlyPlayingAssetId)) {
|
|
456
|
+
NativeAudio.this.clearNotification();
|
|
457
|
+
NativeAudio.this.currentlyPlayingAssetId = null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Delete file if requested
|
|
461
|
+
if (filePathToDelete != null) {
|
|
462
|
+
try {
|
|
463
|
+
File fileToDelete = new File(URI.create(filePathToDelete));
|
|
464
|
+
if (fileToDelete.exists() && fileToDelete.delete()) {
|
|
465
|
+
Log.d(TAG, "Deleted file after playOnce: " + filePathToDelete);
|
|
466
|
+
}
|
|
467
|
+
} catch (Exception e) {
|
|
468
|
+
Log.e(TAG, "Error deleting file after playOnce: " + filePathToDelete, e);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} catch (Exception e) {
|
|
472
|
+
Log.e(TAG, "Error during playOnce cleanup: " + e.getMessage());
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// Auto-play if requested
|
|
480
|
+
if (autoPlay) {
|
|
481
|
+
asset.play(0.0);
|
|
482
|
+
updateTrackedPlaybackState(assetId, PlaybackStateCompat.STATE_PLAYING);
|
|
483
|
+
|
|
484
|
+
// Update notification if enabled
|
|
485
|
+
if (showNotification) {
|
|
486
|
+
currentlyPlayingAssetId = assetId;
|
|
487
|
+
updateNotification(assetId);
|
|
488
|
+
}
|
|
489
|
+
NativeAudio.this.notifyPlaybackState(assetId, "playOnce");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Return the generated assetId
|
|
493
|
+
JSObject result = new JSObject();
|
|
494
|
+
result.put(ASSET_ID, assetId);
|
|
495
|
+
call.resolve(result);
|
|
496
|
+
} catch (Exception ex) {
|
|
497
|
+
// Cleanup on failure
|
|
498
|
+
NativeAudio.this.playOnceAssets.remove(assetId);
|
|
499
|
+
NativeAudio.this.notificationMetadataMap.remove(assetId);
|
|
500
|
+
AudioAsset failedAsset = NativeAudio.this.audioAssetList.get(assetId);
|
|
501
|
+
if (failedAsset != null) {
|
|
502
|
+
failedAsset.unload();
|
|
503
|
+
NativeAudio.this.audioAssetList.remove(assetId);
|
|
504
|
+
}
|
|
505
|
+
NativeAudio.this.clearTrackedPlaybackState(assetId);
|
|
506
|
+
call.reject("Failed to load asset for playOnce: " + ex.getMessage());
|
|
507
|
+
}
|
|
508
|
+
} catch (Exception ex) {
|
|
509
|
+
call.reject(ex.getMessage());
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Starts playback of a preloaded audio asset on the main (UI) thread.
|
|
518
|
+
*
|
|
519
|
+
* The PluginCall must include:
|
|
520
|
+
* - "assetId" (String): identifier of the preloaded asset to play.
|
|
521
|
+
* - Optional "time" (number): start position in seconds.
|
|
522
|
+
*
|
|
523
|
+
* @param call the PluginCall containing playback parameters
|
|
524
|
+
*/
|
|
525
|
+
@PluginMethod
|
|
526
|
+
public void play(final PluginCall call) {
|
|
527
|
+
this.getActivity().runOnUiThread(
|
|
528
|
+
new Runnable() {
|
|
529
|
+
@Override
|
|
530
|
+
public void run() {
|
|
531
|
+
try {
|
|
532
|
+
final String audioId = call.getString(ASSET_ID);
|
|
533
|
+
if (!isStringValid(audioId)) {
|
|
534
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
final double time = call.getDouble(TIME, 0.0);
|
|
539
|
+
final double delaySecs = call.getDouble(DELAY, 0.0);
|
|
540
|
+
final float volume = call.getFloat(VOLUME, 1F);
|
|
541
|
+
final boolean fadeIn = call.getBoolean(FADE_IN, false);
|
|
542
|
+
final double fadeInDurationMs =
|
|
543
|
+
call.getDouble(FADE_IN_DURATION, AudioAsset.DEFAULT_FADE_DURATION_MS / 1000.0) * 1000.0;
|
|
544
|
+
final boolean fadeOut = call.getBoolean(FADE_OUT, false);
|
|
545
|
+
final double fadeOutDurationMs =
|
|
546
|
+
call.getDouble(FADE_OUT_DURATION, AudioAsset.DEFAULT_FADE_DURATION_MS / 1000.0) * 1000.0;
|
|
547
|
+
final double fadeOutStartTimeSecs = call.getDouble(FADE_OUT_START_TIME, 0.0);
|
|
548
|
+
|
|
549
|
+
cancelPendingPlay(audioId);
|
|
550
|
+
clearFadeOutToStopTimer(audioId);
|
|
551
|
+
|
|
552
|
+
if (delaySecs > 0) {
|
|
553
|
+
final Handler handler = new Handler(Looper.getMainLooper());
|
|
554
|
+
final Runnable runnable = new Runnable() {
|
|
555
|
+
@Override
|
|
556
|
+
public void run() {
|
|
557
|
+
pendingPlayHandlers.remove(audioId);
|
|
558
|
+
pendingPlayRunnables.remove(audioId);
|
|
559
|
+
executePlay(
|
|
560
|
+
call,
|
|
561
|
+
audioId,
|
|
562
|
+
time,
|
|
563
|
+
volume,
|
|
564
|
+
fadeIn,
|
|
565
|
+
fadeInDurationMs,
|
|
566
|
+
fadeOut,
|
|
567
|
+
fadeOutDurationMs,
|
|
568
|
+
fadeOutStartTimeSecs
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
pendingPlayHandlers.put(audioId, handler);
|
|
573
|
+
pendingPlayRunnables.put(audioId, runnable);
|
|
574
|
+
handler.postDelayed(runnable, Math.max(0L, (long) (delaySecs * 1000)));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
executePlay(
|
|
579
|
+
call,
|
|
580
|
+
audioId,
|
|
581
|
+
time,
|
|
582
|
+
volume,
|
|
583
|
+
fadeIn,
|
|
584
|
+
fadeInDurationMs,
|
|
585
|
+
fadeOut,
|
|
586
|
+
fadeOutDurationMs,
|
|
587
|
+
fadeOutStartTimeSecs
|
|
588
|
+
);
|
|
589
|
+
} catch (Exception ex) {
|
|
590
|
+
call.reject(ex.getMessage());
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private void executePlay(
|
|
598
|
+
PluginCall call,
|
|
599
|
+
String audioId,
|
|
600
|
+
double time,
|
|
601
|
+
float volume,
|
|
602
|
+
boolean fadeIn,
|
|
603
|
+
double fadeInDurationMs,
|
|
604
|
+
boolean fadeOut,
|
|
605
|
+
double fadeOutDurationMs,
|
|
606
|
+
double fadeOutStartTimeSecs
|
|
607
|
+
) {
|
|
608
|
+
try {
|
|
609
|
+
if (!audioAssetList.containsKey(audioId)) {
|
|
610
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
615
|
+
if (asset == null) {
|
|
616
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING + " - " + audioId);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (fadeIn) {
|
|
621
|
+
asset.playWithFadeIn(time, volume, fadeInDurationMs);
|
|
622
|
+
} else {
|
|
623
|
+
asset.play(time, volume);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (fadeOut) {
|
|
627
|
+
handleFadeOut(asset, audioId, fadeOutDurationMs, fadeOutStartTimeSecs);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PLAYING);
|
|
631
|
+
|
|
632
|
+
if (showNotification) {
|
|
633
|
+
currentlyPlayingAssetId = audioId;
|
|
634
|
+
updateNotification(audioId);
|
|
635
|
+
}
|
|
636
|
+
notifyPlaybackState(audioId, "play");
|
|
637
|
+
|
|
638
|
+
call.resolve();
|
|
639
|
+
} catch (Exception ex) {
|
|
640
|
+
call.reject(ex.getMessage());
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private void cancelPendingPlay(String audioId) {
|
|
645
|
+
if (audioId == null) return;
|
|
646
|
+
Handler handler = pendingPlayHandlers.remove(audioId);
|
|
647
|
+
Runnable runnable = pendingPlayRunnables.remove(audioId);
|
|
648
|
+
if (handler != null && runnable != null) {
|
|
649
|
+
handler.removeCallbacks(runnable);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
@PluginMethod
|
|
654
|
+
public void getCurrentTime(final PluginCall call) {
|
|
655
|
+
try {
|
|
656
|
+
initSoundPool();
|
|
657
|
+
|
|
658
|
+
String audioId = call.getString(ASSET_ID);
|
|
659
|
+
|
|
660
|
+
if (!isStringValid(audioId)) {
|
|
661
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
666
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
667
|
+
if (asset != null) {
|
|
668
|
+
call.resolve(new JSObject().put("currentTime", asset.getCurrentPosition()));
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING + " - " + audioId);
|
|
672
|
+
}
|
|
673
|
+
} catch (Exception ex) {
|
|
674
|
+
call.reject(ex.getMessage());
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
@PluginMethod
|
|
679
|
+
public void getDuration(PluginCall call) {
|
|
680
|
+
try {
|
|
681
|
+
String audioId = call.getString(ASSET_ID);
|
|
682
|
+
if (!isStringValid(audioId)) {
|
|
683
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
687
|
+
if (asset != null) {
|
|
688
|
+
double duration = asset.getDuration();
|
|
689
|
+
if (duration > 0) {
|
|
690
|
+
JSObject ret = new JSObject();
|
|
691
|
+
ret.put("duration", duration);
|
|
692
|
+
call.resolve(ret);
|
|
693
|
+
} else {
|
|
694
|
+
saveDurationCall(audioId, call);
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
698
|
+
}
|
|
699
|
+
} catch (Exception ex) {
|
|
700
|
+
call.reject(ex.getMessage());
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
@PluginMethod
|
|
705
|
+
public void loop(final PluginCall call) {
|
|
706
|
+
this.getActivity().runOnUiThread(
|
|
707
|
+
new Runnable() {
|
|
708
|
+
@Override
|
|
709
|
+
public void run() {
|
|
710
|
+
String audioId = call.getString(ASSET_ID);
|
|
711
|
+
cancelPendingPlay(audioId);
|
|
712
|
+
clearFadeOutToStopTimer(audioId);
|
|
713
|
+
playOrLoop("loop", call);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@PluginMethod
|
|
720
|
+
public void pause(PluginCall call) {
|
|
721
|
+
try {
|
|
722
|
+
initSoundPool();
|
|
723
|
+
String audioId = call.getString(ASSET_ID);
|
|
724
|
+
final boolean fadeOut = call.getBoolean(FADE_OUT, false);
|
|
725
|
+
final double fadeOutDurationMs = call.getDouble(FADE_OUT_DURATION, AudioAsset.DEFAULT_FADE_DURATION_MS / 1000.0) * 1000.0;
|
|
726
|
+
|
|
727
|
+
cancelPendingPlay(audioId);
|
|
728
|
+
|
|
729
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
730
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
731
|
+
if (asset != null) {
|
|
732
|
+
boolean wasPlaying = asset.isPlaying();
|
|
733
|
+
|
|
734
|
+
JSObject data = getAudioAssetData(audioId);
|
|
735
|
+
data.put("volumeBeforePause", asset.getVolume());
|
|
736
|
+
setAudioAssetData(audioId, data);
|
|
737
|
+
|
|
738
|
+
if (fadeOut) {
|
|
739
|
+
asset.stopWithFade(fadeOutDurationMs, true);
|
|
740
|
+
} else {
|
|
741
|
+
asset.pause();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (wasPlaying) {
|
|
745
|
+
resumeList.add(asset);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PAUSED);
|
|
749
|
+
|
|
750
|
+
// Update notification when paused
|
|
751
|
+
if (showNotification) {
|
|
752
|
+
updatePlaybackState(PlaybackStateCompat.STATE_PAUSED);
|
|
753
|
+
updateNotification(audioId);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
notifyPlaybackState(audioId, "pause");
|
|
757
|
+
|
|
758
|
+
call.resolve();
|
|
759
|
+
} else {
|
|
760
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
761
|
+
}
|
|
762
|
+
} else {
|
|
763
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
764
|
+
}
|
|
765
|
+
} catch (Exception ex) {
|
|
766
|
+
call.reject(ex.getMessage());
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
@PluginMethod
|
|
771
|
+
public void resume(PluginCall call) {
|
|
772
|
+
try {
|
|
773
|
+
initSoundPool();
|
|
774
|
+
String audioId = call.getString(ASSET_ID);
|
|
775
|
+
final boolean fadeIn = call.getBoolean(FADE_IN, false);
|
|
776
|
+
final double fadeInDurationMs = call.getDouble(FADE_IN_DURATION, AudioAsset.DEFAULT_FADE_DURATION_MS / 1000.0) * 1000.0;
|
|
777
|
+
|
|
778
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
779
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
780
|
+
if (asset != null) {
|
|
781
|
+
JSObject data = getAudioAssetData(audioId);
|
|
782
|
+
float volumeBeforePause = (float) data.optDouble("volumeBeforePause", asset.getVolume());
|
|
783
|
+
|
|
784
|
+
if (fadeIn) {
|
|
785
|
+
asset.setVolume(0f, 0);
|
|
786
|
+
asset.resume();
|
|
787
|
+
asset.setVolume(volumeBeforePause, fadeInDurationMs);
|
|
788
|
+
} else {
|
|
789
|
+
asset.setVolume(volumeBeforePause, 0);
|
|
790
|
+
asset.resume();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
data.remove("volumeBeforePause");
|
|
794
|
+
setAudioAssetData(audioId, data);
|
|
795
|
+
resumeList.add(asset);
|
|
796
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PLAYING);
|
|
797
|
+
|
|
798
|
+
// Update notification when resumed
|
|
799
|
+
if (showNotification) {
|
|
800
|
+
currentlyPlayingAssetId = audioId;
|
|
801
|
+
updatePlaybackState(PlaybackStateCompat.STATE_PLAYING);
|
|
802
|
+
updateNotification(audioId);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
notifyPlaybackState(audioId, "resume");
|
|
806
|
+
|
|
807
|
+
call.resolve();
|
|
808
|
+
} else {
|
|
809
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
810
|
+
}
|
|
811
|
+
} else {
|
|
812
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
813
|
+
}
|
|
814
|
+
} catch (Exception ex) {
|
|
815
|
+
call.reject(ex.getMessage());
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
@PluginMethod
|
|
820
|
+
public void stop(final PluginCall call) {
|
|
821
|
+
this.getActivity().runOnUiThread(
|
|
822
|
+
new Runnable() {
|
|
823
|
+
@Override
|
|
824
|
+
public void run() {
|
|
825
|
+
try {
|
|
826
|
+
String audioId = call.getString(ASSET_ID);
|
|
827
|
+
boolean fadeOut = call.getBoolean(FADE_OUT, false);
|
|
828
|
+
double fadeOutDurationMs = call.getDouble(FADE_OUT_DURATION, AudioAsset.DEFAULT_FADE_DURATION_MS / 1000.0) * 1000.0;
|
|
829
|
+
|
|
830
|
+
if (!isStringValid(audioId)) {
|
|
831
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
cancelPendingPlay(audioId);
|
|
836
|
+
clearFadeOutToStopTimer(audioId);
|
|
837
|
+
stopAudio(audioId, fadeOut, fadeOutDurationMs);
|
|
838
|
+
audioData.remove(audioId);
|
|
839
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_STOPPED);
|
|
840
|
+
|
|
841
|
+
// Clear notification when stopped
|
|
842
|
+
if (showNotification) {
|
|
843
|
+
clearNotification();
|
|
844
|
+
currentlyPlayingAssetId = null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
notifyPlaybackState(audioId, "stop");
|
|
848
|
+
|
|
849
|
+
call.resolve();
|
|
850
|
+
} catch (Exception ex) {
|
|
851
|
+
call.reject(ex.getMessage());
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
@PluginMethod
|
|
859
|
+
public void unload(PluginCall call) {
|
|
860
|
+
try {
|
|
861
|
+
initSoundPool();
|
|
862
|
+
new JSObject();
|
|
863
|
+
JSObject status;
|
|
864
|
+
if (isStringValid(call.getString(ASSET_ID))) {
|
|
865
|
+
String audioId = call.getString(ASSET_ID);
|
|
866
|
+
cancelPendingPlay(audioId);
|
|
867
|
+
pendingPlayHandlers.remove(audioId);
|
|
868
|
+
pendingPlayRunnables.remove(audioId);
|
|
869
|
+
audioData.remove(audioId);
|
|
870
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
871
|
+
if (asset != null) {
|
|
872
|
+
clearFadeOutToStopTimer(audioId);
|
|
873
|
+
asset.unload();
|
|
874
|
+
audioAssetList.remove(audioId);
|
|
875
|
+
clearTrackedPlaybackState(audioId);
|
|
876
|
+
call.resolve();
|
|
877
|
+
} else {
|
|
878
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING + " - " + audioId);
|
|
879
|
+
}
|
|
880
|
+
} else {
|
|
881
|
+
call.reject(ERROR_AUDIO_ID_MISSING);
|
|
882
|
+
}
|
|
883
|
+
} catch (Exception ex) {
|
|
884
|
+
String audioId = call.getString(ASSET_ID);
|
|
885
|
+
if (audioId != null) {
|
|
886
|
+
pendingPlayHandlers.remove(audioId);
|
|
887
|
+
pendingPlayRunnables.remove(audioId);
|
|
888
|
+
audioData.remove(audioId);
|
|
889
|
+
}
|
|
890
|
+
call.reject(ex.getMessage());
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
@PluginMethod
|
|
895
|
+
public void setVolume(PluginCall call) {
|
|
896
|
+
try {
|
|
897
|
+
initSoundPool();
|
|
898
|
+
|
|
899
|
+
String audioId = call.getString(ASSET_ID);
|
|
900
|
+
float volume = call.getFloat(VOLUME, 1F);
|
|
901
|
+
double durationSecs = call.getDouble(DURATION, 0.0);
|
|
902
|
+
|
|
903
|
+
if (durationSecs > 0) {
|
|
904
|
+
logger.debug("setVolume " + volume + " over duration " + durationSecs + " seconds");
|
|
905
|
+
} else {
|
|
906
|
+
logger.debug("setVolume " + volume);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
910
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
911
|
+
if (asset != null) {
|
|
912
|
+
double durationMs = durationSecs * 1000;
|
|
913
|
+
asset.setVolume(volume, durationMs);
|
|
914
|
+
call.resolve();
|
|
915
|
+
} else {
|
|
916
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING);
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING);
|
|
920
|
+
}
|
|
921
|
+
} catch (Exception ex) {
|
|
922
|
+
call.reject(ex.getMessage());
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
@PluginMethod
|
|
927
|
+
public void setRate(PluginCall call) {
|
|
928
|
+
try {
|
|
929
|
+
initSoundPool();
|
|
930
|
+
|
|
931
|
+
String audioId = call.getString(ASSET_ID);
|
|
932
|
+
float rate = call.getFloat(RATE, 1F);
|
|
933
|
+
|
|
934
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
935
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
936
|
+
if (asset != null) {
|
|
937
|
+
asset.setRate(rate);
|
|
938
|
+
}
|
|
939
|
+
call.resolve();
|
|
940
|
+
} else {
|
|
941
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING);
|
|
942
|
+
}
|
|
943
|
+
} catch (Exception ex) {
|
|
944
|
+
call.reject(ex.getMessage());
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
@PluginMethod
|
|
949
|
+
public void isPlaying(final PluginCall call) {
|
|
950
|
+
try {
|
|
951
|
+
initSoundPool();
|
|
952
|
+
|
|
953
|
+
String audioId = call.getString(ASSET_ID);
|
|
954
|
+
|
|
955
|
+
if (!isStringValid(audioId)) {
|
|
956
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
961
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
962
|
+
if (asset != null) {
|
|
963
|
+
call.resolve(new JSObject().put("isPlaying", asset.isPlaying()));
|
|
964
|
+
} else {
|
|
965
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING + " - " + audioId);
|
|
966
|
+
}
|
|
967
|
+
} else {
|
|
968
|
+
call.reject(ERROR_AUDIO_ASSET_MISSING + " - " + audioId);
|
|
969
|
+
}
|
|
970
|
+
} catch (Exception ex) {
|
|
971
|
+
call.reject(ex.getMessage());
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
@PluginMethod
|
|
976
|
+
public void clearCache(PluginCall call) {
|
|
977
|
+
try {
|
|
978
|
+
RemoteAudioAsset.clearCache(getContext());
|
|
979
|
+
call.resolve();
|
|
980
|
+
} catch (Exception ex) {
|
|
981
|
+
call.reject(ex.getMessage());
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
@PluginMethod
|
|
986
|
+
public void setCurrentTime(final PluginCall call) {
|
|
987
|
+
try {
|
|
988
|
+
initSoundPool();
|
|
989
|
+
|
|
990
|
+
String audioId = call.getString(ASSET_ID);
|
|
991
|
+
clearFadeOutToStopTimer(audioId);
|
|
992
|
+
double time = call.getDouble(TIME, 0.0);
|
|
993
|
+
|
|
994
|
+
cancelPendingPlay(audioId);
|
|
995
|
+
|
|
996
|
+
if (!isStringValid(audioId)) {
|
|
997
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
1002
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1003
|
+
if (asset != null) {
|
|
1004
|
+
this.getActivity().runOnUiThread(
|
|
1005
|
+
new Runnable() {
|
|
1006
|
+
@Override
|
|
1007
|
+
public void run() {
|
|
1008
|
+
try {
|
|
1009
|
+
asset.setCurrentTime(time);
|
|
1010
|
+
call.resolve();
|
|
1011
|
+
} catch (Exception e) {
|
|
1012
|
+
call.reject("Error setting current time: " + e.getMessage());
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
);
|
|
1017
|
+
} else {
|
|
1018
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
1019
|
+
}
|
|
1020
|
+
} else {
|
|
1021
|
+
call.reject(ERROR_ASSET_NOT_LOADED + " - " + audioId);
|
|
1022
|
+
}
|
|
1023
|
+
} catch (Exception ex) {
|
|
1024
|
+
call.reject(ex.getMessage());
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
public void dispatchComplete(String assetId) {
|
|
1029
|
+
JSObject ret = new JSObject();
|
|
1030
|
+
ret.put("assetId", assetId);
|
|
1031
|
+
notifyListeners("complete", ret);
|
|
1032
|
+
|
|
1033
|
+
updateTrackedPlaybackState(assetId, PlaybackStateCompat.STATE_STOPPED);
|
|
1034
|
+
|
|
1035
|
+
if (assetId != null && assetId.equals(currentlyPlayingAssetId)) {
|
|
1036
|
+
clearNotification();
|
|
1037
|
+
currentlyPlayingAssetId = null;
|
|
1038
|
+
}
|
|
1039
|
+
notifyPlaybackState(assetId, "complete");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Emits a "currentTime" event for the given asset with the playback position rounded to the nearest 0.1 second.
|
|
1044
|
+
*
|
|
1045
|
+
* The emitted event payload contains `assetId` and `currentTime` (in seconds, rounded to the nearest 0.1).
|
|
1046
|
+
*
|
|
1047
|
+
* @param assetId the identifier of the audio asset
|
|
1048
|
+
* @param currentTime the current playback time in seconds (will be rounded to nearest 0.1)
|
|
1049
|
+
*/
|
|
1050
|
+
public void notifyCurrentTime(String assetId, double currentTime) {
|
|
1051
|
+
// Round to nearest 100ms
|
|
1052
|
+
double roundedTime = Math.round(currentTime * 10.0) / 10.0;
|
|
1053
|
+
JSObject ret = new JSObject();
|
|
1054
|
+
ret.put("currentTime", roundedTime);
|
|
1055
|
+
ret.put("assetId", assetId);
|
|
1056
|
+
notifyListeners("currentTime", ret);
|
|
1057
|
+
|
|
1058
|
+
JSObject data = getAudioAssetData(assetId);
|
|
1059
|
+
if (data.optBoolean("fadeOut", false)) {
|
|
1060
|
+
double fadeOutStartTime = data.optDouble("fadeOutStartTime", -1);
|
|
1061
|
+
if (fadeOutStartTime >= 0 && currentTime >= fadeOutStartTime) {
|
|
1062
|
+
double fadeOutDuration = data.optDouble("fadeOutDuration", AudioAsset.DEFAULT_FADE_DURATION_MS);
|
|
1063
|
+
try {
|
|
1064
|
+
AudioAsset asset = audioAssetList.get(assetId);
|
|
1065
|
+
if (asset != null) {
|
|
1066
|
+
asset.stopWithFade(fadeOutDuration, false);
|
|
1067
|
+
}
|
|
1068
|
+
} catch (Exception e) {
|
|
1069
|
+
logger.error("Error triggering scheduled fade-out", e);
|
|
1070
|
+
}
|
|
1071
|
+
clearFadeOutToStopTimer(assetId);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Create an AudioAsset for the given identifier and path, supporting remote URLs (including HLS),
|
|
1078
|
+
* local file URIs, and assets in the app's public folder.
|
|
1079
|
+
*
|
|
1080
|
+
* @param assetId unique identifier for the asset
|
|
1081
|
+
* @param assetPath file path or URL to the audio resource
|
|
1082
|
+
* @param isLocalUrl true when assetPath is a URL (http/https/file), false when it refers to a public asset path
|
|
1083
|
+
* @param volume initial playback volume (expected range: 0.1 to 1.0)
|
|
1084
|
+
* @param audioChannelNum number of audio channels to configure for the asset
|
|
1085
|
+
* @param headersObj optional HTTP headers for remote requests (may be null)
|
|
1086
|
+
* @return an initialized AudioAsset instance for the provided path
|
|
1087
|
+
* @throws Exception if the asset cannot be located or initialized (includes missing file, invalid path, or other load errors)
|
|
1088
|
+
*/
|
|
1089
|
+
private AudioAsset loadAudioAsset(
|
|
1090
|
+
String assetId,
|
|
1091
|
+
String assetPath,
|
|
1092
|
+
boolean isLocalUrl,
|
|
1093
|
+
float volume,
|
|
1094
|
+
int audioChannelNum,
|
|
1095
|
+
JSObject headersObj
|
|
1096
|
+
) throws Exception {
|
|
1097
|
+
if (isLocalUrl) {
|
|
1098
|
+
Uri uri = Uri.parse(assetPath);
|
|
1099
|
+
if (uri.getScheme() != null && (uri.getScheme().equals("http") || uri.getScheme().equals("https"))) {
|
|
1100
|
+
// Remote URL
|
|
1101
|
+
Map<String, String> requestHeaders = null;
|
|
1102
|
+
if (headersObj != null) {
|
|
1103
|
+
requestHeaders = new HashMap<>();
|
|
1104
|
+
for (Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
|
1105
|
+
String key = it.next();
|
|
1106
|
+
try {
|
|
1107
|
+
String value = headersObj.getString(key);
|
|
1108
|
+
if (value != null) {
|
|
1109
|
+
requestHeaders.put(key, value);
|
|
1110
|
+
}
|
|
1111
|
+
} catch (Exception e) {
|
|
1112
|
+
Log.w("AudioPlugin", "Skipping non-string header: " + key);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (isHlsUrl(assetPath)) {
|
|
1118
|
+
// HLS Stream - check if HLS support is available
|
|
1119
|
+
if (!HlsAvailabilityChecker.isHlsAvailable()) {
|
|
1120
|
+
throw new Exception(
|
|
1121
|
+
"HLS streaming (.m3u8) is not available. " + "Set 'hls: true' in capacitor.config.ts and run 'npx cap sync'."
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
AudioAsset streamAudioAsset = createStreamAudioAsset(assetId, uri, volume, requestHeaders);
|
|
1125
|
+
if (streamAudioAsset == null) {
|
|
1126
|
+
throw new Exception("Failed to create HLS stream player. HLS may not be configured.");
|
|
1127
|
+
}
|
|
1128
|
+
return streamAudioAsset;
|
|
1129
|
+
} else {
|
|
1130
|
+
RemoteAudioAsset remoteAudioAsset = new RemoteAudioAsset(this, assetId, uri, audioChannelNum, volume, requestHeaders);
|
|
1131
|
+
return remoteAudioAsset;
|
|
1132
|
+
}
|
|
1133
|
+
} else if (uri.getScheme() != null && uri.getScheme().equals("file")) {
|
|
1134
|
+
File file = new File(uri.getPath());
|
|
1135
|
+
if (!file.exists()) {
|
|
1136
|
+
throw new Exception(ERROR_ASSET_PATH_MISSING + " - " + assetPath);
|
|
1137
|
+
}
|
|
1138
|
+
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
|
1139
|
+
AssetFileDescriptor afd = new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
|
|
1140
|
+
AudioAsset asset = new AudioAsset(this, assetId, afd, audioChannelNum, volume);
|
|
1141
|
+
return asset;
|
|
1142
|
+
} else {
|
|
1143
|
+
// Handle unexpected URI schemes by attempting to treat as local file
|
|
1144
|
+
try {
|
|
1145
|
+
File file = new File(uri.getPath());
|
|
1146
|
+
if (!file.exists()) {
|
|
1147
|
+
throw new Exception(ERROR_ASSET_PATH_MISSING + " - " + assetPath);
|
|
1148
|
+
}
|
|
1149
|
+
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
|
1150
|
+
AssetFileDescriptor afd = new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
|
|
1151
|
+
AudioAsset asset = new AudioAsset(this, assetId, afd, audioChannelNum, volume);
|
|
1152
|
+
Log.w(TAG, "Unexpected URI scheme '" + uri.getScheme() + "' treated as local file: " + assetPath);
|
|
1153
|
+
return asset;
|
|
1154
|
+
} catch (Exception e) {
|
|
1155
|
+
throw new Exception(
|
|
1156
|
+
"Failed to load asset with unexpected URI scheme '" +
|
|
1157
|
+
uri.getScheme() +
|
|
1158
|
+
"' (expected 'http', 'https', or 'file'). Asset path: " +
|
|
1159
|
+
assetPath +
|
|
1160
|
+
". Error: " +
|
|
1161
|
+
e.getMessage()
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
} else {
|
|
1166
|
+
// Handle asset in public folder
|
|
1167
|
+
String finalAssetPath = assetPath;
|
|
1168
|
+
if (!assetPath.startsWith("public/")) {
|
|
1169
|
+
finalAssetPath = "public/" + assetPath;
|
|
1170
|
+
}
|
|
1171
|
+
Context ctx = getContext().getApplicationContext();
|
|
1172
|
+
AssetManager am = ctx.getResources().getAssets();
|
|
1173
|
+
AssetFileDescriptor assetFileDescriptor = am.openFd(finalAssetPath);
|
|
1174
|
+
AudioAsset asset = new AudioAsset(this, assetId, assetFileDescriptor, audioChannelNum, volume);
|
|
1175
|
+
return asset;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Preloads an audio asset into the plugin's asset list.
|
|
1181
|
+
*
|
|
1182
|
+
* <p>The provided PluginCall must include:
|
|
1183
|
+
* <ul>
|
|
1184
|
+
* <li>`assetId` (string) — identifier for the asset</li>
|
|
1185
|
+
* <li>`assetPath` (string) — path or URL to the audio resource</li>
|
|
1186
|
+
* </ul>
|
|
1187
|
+
* Optional keys on the call:
|
|
1188
|
+
* <ul>
|
|
1189
|
+
* <li>`isUrl` (boolean) — true when `assetPath` is a remote URL</li>
|
|
1190
|
+
* <li>`isComplex` (boolean) — when true, `volume` and `audioChannelNum` may be provided</li>
|
|
1191
|
+
* <li>`volume` (number) — initial playback volume (default 1.0)</li>
|
|
1192
|
+
* <li>`audioChannelNum` (int) — audio channel count (default 1)</li>
|
|
1193
|
+
* <li>`headers` (object) — HTTP headers for remote requests</li>
|
|
1194
|
+
* <li>`notificationMetadata` (object) — optional metadata (`title`, `artist`, `album`, `artworkUrl`) to attach to the asset</li>
|
|
1195
|
+
* </ul>
|
|
1196
|
+
*
|
|
1197
|
+
* <p>On success the call is resolved with a status indicating success. The method rejects the call
|
|
1198
|
+
* when required parameters are missing, when an asset with the same id already exists, or when
|
|
1199
|
+
* the asset cannot be loaded.
|
|
1200
|
+
*
|
|
1201
|
+
* @param call the PluginCall containing asset parameters and options
|
|
1202
|
+
*/
|
|
1203
|
+
private void preloadAsset(PluginCall call) {
|
|
1204
|
+
float volume = 1F;
|
|
1205
|
+
int audioChannelNum = 1;
|
|
1206
|
+
JSObject status = new JSObject();
|
|
1207
|
+
status.put("STATUS", "OK");
|
|
1208
|
+
|
|
1209
|
+
try {
|
|
1210
|
+
initSoundPool();
|
|
1211
|
+
|
|
1212
|
+
String audioId = call.getString(ASSET_ID);
|
|
1213
|
+
if (!isStringValid(audioId)) {
|
|
1214
|
+
call.reject(ERROR_AUDIO_ID_MISSING + " - " + audioId);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
String assetPath = call.getString(ASSET_PATH);
|
|
1219
|
+
if (!isStringValid(assetPath)) {
|
|
1220
|
+
call.reject(ERROR_ASSET_PATH_MISSING + " - " + audioId + " - " + assetPath);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
boolean isLocalUrl = call.getBoolean("isUrl", false);
|
|
1225
|
+
boolean isComplex = call.getBoolean("isComplex", false);
|
|
1226
|
+
|
|
1227
|
+
Log.d(
|
|
1228
|
+
TAG,
|
|
1229
|
+
"Preloading asset: " + audioId + ", path: " + assetPath + ", isLocalUrl: " + isLocalUrl + ", isComplex: " + isComplex
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
1233
|
+
call.reject(ERROR_AUDIO_EXISTS + " - " + audioId);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (isComplex) {
|
|
1238
|
+
volume = call.getFloat(VOLUME, 1F);
|
|
1239
|
+
audioChannelNum = call.getInt(AUDIO_CHANNEL_NUM, 1);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Store notification metadata if provided
|
|
1243
|
+
JSObject metadata = call.getObject(NOTIFICATION_METADATA);
|
|
1244
|
+
if (metadata != null) {
|
|
1245
|
+
Map<String, String> metadataMap = new HashMap<>();
|
|
1246
|
+
if (metadata.has("title")) metadataMap.put("title", metadata.getString("title"));
|
|
1247
|
+
if (metadata.has("artist")) metadataMap.put("artist", metadata.getString("artist"));
|
|
1248
|
+
if (metadata.has("album")) metadataMap.put("album", metadata.getString("album"));
|
|
1249
|
+
if (metadata.has("artworkUrl")) metadataMap.put("artworkUrl", metadata.getString("artworkUrl"));
|
|
1250
|
+
if (!metadataMap.isEmpty()) {
|
|
1251
|
+
notificationMetadataMap.put(audioId, metadataMap);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Use the helper method to load the asset
|
|
1256
|
+
JSObject headersObj = call.getObject("headers");
|
|
1257
|
+
AudioAsset asset = loadAudioAsset(audioId, assetPath, isLocalUrl, volume, audioChannelNum, headersObj);
|
|
1258
|
+
|
|
1259
|
+
if (asset == null) {
|
|
1260
|
+
call.reject("Failed to load asset");
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Set completion listener and add to asset list
|
|
1265
|
+
asset.setCompletionListener(this::dispatchComplete);
|
|
1266
|
+
audioAssetList.put(audioId, asset);
|
|
1267
|
+
call.resolve(status);
|
|
1268
|
+
} catch (Exception ex) {
|
|
1269
|
+
Log.e("AudioPlugin", "Error in preloadAsset", ex);
|
|
1270
|
+
call.reject("Error in preloadAsset: " + ex.getMessage());
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private void playOrLoop(String action, final PluginCall call) {
|
|
1275
|
+
try {
|
|
1276
|
+
final String audioId = call.getString(ASSET_ID);
|
|
1277
|
+
final Double time = call.getDouble("time", 0.0);
|
|
1278
|
+
Log.d(TAG, "Playing asset: " + audioId + ", action: " + action + ", assets count: " + audioAssetList.size());
|
|
1279
|
+
|
|
1280
|
+
if (audioAssetList.containsKey(audioId)) {
|
|
1281
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1282
|
+
Log.d(TAG, "Found asset: " + audioId + ", type: " + asset.getClass().getSimpleName());
|
|
1283
|
+
|
|
1284
|
+
if (asset != null) {
|
|
1285
|
+
if (LOOP.equals(action)) {
|
|
1286
|
+
asset.loop();
|
|
1287
|
+
} else {
|
|
1288
|
+
asset.play(time);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PLAYING);
|
|
1292
|
+
|
|
1293
|
+
// Update notification if enabled
|
|
1294
|
+
if (showNotification) {
|
|
1295
|
+
currentlyPlayingAssetId = audioId;
|
|
1296
|
+
updateNotification(audioId);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
notifyPlaybackState(audioId, LOOP.equals(action) ? "loop" : "play");
|
|
1300
|
+
|
|
1301
|
+
call.resolve();
|
|
1302
|
+
} else {
|
|
1303
|
+
call.reject("Asset is null: " + audioId);
|
|
1304
|
+
}
|
|
1305
|
+
} else {
|
|
1306
|
+
call.reject("Asset not found: " + audioId);
|
|
1307
|
+
}
|
|
1308
|
+
} catch (Exception ex) {
|
|
1309
|
+
logger.error("Error in playOrLoop", ex);
|
|
1310
|
+
call.reject(ex.getMessage());
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
private void scheduleFadeOut(AudioAsset asset, double fadeOutDurationMs, double fadeOutStartTimeMs) {
|
|
1315
|
+
try {
|
|
1316
|
+
double duration = asset.getDuration();
|
|
1317
|
+
if (duration > 0) {
|
|
1318
|
+
double fadeOutStartTime = duration - (fadeOutDurationMs / 1000.0);
|
|
1319
|
+
if (fadeOutStartTimeMs > 0) {
|
|
1320
|
+
fadeOutStartTime = fadeOutStartTimeMs / 1000.0;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
logger.debug("Scheduling fade-out for asset: " + asset.assetId + ", start time: " + fadeOutStartTime + " seconds");
|
|
1324
|
+
|
|
1325
|
+
// Store fade-out parameters in asset data
|
|
1326
|
+
JSObject data = getAudioAssetData(asset.assetId);
|
|
1327
|
+
data.put("fadeOut", true);
|
|
1328
|
+
data.put("fadeOutStartTime", fadeOutStartTime);
|
|
1329
|
+
data.put("fadeOutDuration", fadeOutDurationMs);
|
|
1330
|
+
setAudioAssetData(asset.assetId, data);
|
|
1331
|
+
} else {
|
|
1332
|
+
logger.warning("Duration not available, skipping fade-out scheduling");
|
|
1333
|
+
}
|
|
1334
|
+
} catch (Exception e) {
|
|
1335
|
+
logger.error("Error handling fade-out", e);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private void handleFadeOut(AudioAsset asset, String audioId, double fadeOutDurationMs, double fadeOutStartTimeSecs) {
|
|
1340
|
+
try {
|
|
1341
|
+
double duration = asset.getDuration();
|
|
1342
|
+
if (duration <= 0) {
|
|
1343
|
+
logger.warning("Duration not available, skipping fade-out scheduling");
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
double fadeOutStartTime = duration - (fadeOutDurationMs / 1000.0);
|
|
1348
|
+
if (fadeOutStartTimeSecs > 0) {
|
|
1349
|
+
fadeOutStartTime = fadeOutStartTimeSecs;
|
|
1350
|
+
}
|
|
1351
|
+
fadeOutStartTime = Math.max(fadeOutStartTime, 0);
|
|
1352
|
+
|
|
1353
|
+
JSObject data = getAudioAssetData(audioId);
|
|
1354
|
+
data.put("fadeOut", true);
|
|
1355
|
+
data.put("fadeOutStartTime", fadeOutStartTime);
|
|
1356
|
+
data.put("fadeOutDuration", fadeOutDurationMs);
|
|
1357
|
+
setAudioAssetData(audioId, data);
|
|
1358
|
+
} catch (Exception e) {
|
|
1359
|
+
logger.error("Error scheduling fade-out", e);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
private void clearFadeOutToStopTimer(String audioId) {
|
|
1364
|
+
JSObject data = getAudioAssetData(audioId);
|
|
1365
|
+
if (data.has("fadeOut")) {
|
|
1366
|
+
logger.debug("Cancelling fade-out for asset: " + audioId);
|
|
1367
|
+
data.remove("fadeOut");
|
|
1368
|
+
data.remove("fadeOutStartTime");
|
|
1369
|
+
data.remove("fadeOutDuration");
|
|
1370
|
+
setAudioAssetData(audioId, data);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
private void initSoundPool() {
|
|
1375
|
+
if (audioAssetList == null) {
|
|
1376
|
+
logger.debug("Initializing audio asset list");
|
|
1377
|
+
audioAssetList = new ConcurrentHashMap<>();
|
|
1378
|
+
}
|
|
1379
|
+
if (resumeList == null) {
|
|
1380
|
+
logger.debug("Initializing resume list");
|
|
1381
|
+
resumeList = new CopyOnWriteArrayList<>();
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
private boolean isStringValid(String value) {
|
|
1386
|
+
return (value != null && !value.isEmpty() && !value.equals("null"));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Check if the given URL is an HLS stream by examining the URL path.
|
|
1391
|
+
* This handles URLs with query parameters correctly.
|
|
1392
|
+
*
|
|
1393
|
+
* @param assetPath The URL or path to check
|
|
1394
|
+
* @return true if the URL path ends with .m3u8, false otherwise
|
|
1395
|
+
*/
|
|
1396
|
+
private boolean isHlsUrl(String assetPath) {
|
|
1397
|
+
if (assetPath == null || assetPath.isEmpty()) {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
try {
|
|
1402
|
+
Uri uri = Uri.parse(assetPath);
|
|
1403
|
+
String path = uri.getPath();
|
|
1404
|
+
if (path != null) {
|
|
1405
|
+
return path.toLowerCase().endsWith(".m3u8");
|
|
1406
|
+
}
|
|
1407
|
+
} catch (Exception e) {
|
|
1408
|
+
Log.w(TAG, "Failed to parse URL for HLS detection: " + assetPath, e);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Fallback: check if the URL contains .m3u8 followed by nothing or query params
|
|
1412
|
+
return assetPath.toLowerCase().contains(".m3u8");
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Creates a StreamAudioAsset via reflection.
|
|
1417
|
+
* This allows the StreamAudioAsset class to be excluded at compile time when HLS is disabled,
|
|
1418
|
+
* reducing APK size by ~4MB.
|
|
1419
|
+
*
|
|
1420
|
+
* @param audioId The unique identifier for the audio asset
|
|
1421
|
+
* @param uri The URI of the HLS stream
|
|
1422
|
+
* @param volume The initial volume (0.0 to 1.0)
|
|
1423
|
+
* @param headers Optional HTTP headers for the request
|
|
1424
|
+
* @return The created AudioAsset, or null if creation failed
|
|
1425
|
+
*/
|
|
1426
|
+
private AudioAsset createStreamAudioAsset(String audioId, Uri uri, float volume, java.util.Map<String, String> headers) {
|
|
1427
|
+
try {
|
|
1428
|
+
Class<?> streamAudioAssetClass = Class.forName("ee.forgr.audio.StreamAudioAsset");
|
|
1429
|
+
java.lang.reflect.Constructor<?> constructor = streamAudioAssetClass.getConstructor(
|
|
1430
|
+
NativeAudio.class,
|
|
1431
|
+
String.class,
|
|
1432
|
+
Uri.class,
|
|
1433
|
+
float.class,
|
|
1434
|
+
java.util.Map.class
|
|
1435
|
+
);
|
|
1436
|
+
return (AudioAsset) constructor.newInstance(this, audioId, uri, volume, headers);
|
|
1437
|
+
} catch (ClassNotFoundException e) {
|
|
1438
|
+
Log.e(TAG, "StreamAudioAsset class not found. HLS support is not included in this build.", e);
|
|
1439
|
+
return null;
|
|
1440
|
+
} catch (Exception e) {
|
|
1441
|
+
Log.e(TAG, "Failed to create StreamAudioAsset", e);
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
private void stopAudio(String audioId, boolean fadeOut, double fadeOutDurationMs) throws Exception {
|
|
1447
|
+
if (!audioAssetList.containsKey(audioId)) {
|
|
1448
|
+
throw new Exception(ERROR_ASSET_NOT_LOADED);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1452
|
+
if (asset != null) {
|
|
1453
|
+
if (fadeOut) {
|
|
1454
|
+
asset.stopWithFade(fadeOutDurationMs, false);
|
|
1455
|
+
} else {
|
|
1456
|
+
asset.stop();
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
private void saveDurationCall(String audioId, PluginCall call) {
|
|
1462
|
+
logger.debug("Saving duration call for later: " + audioId);
|
|
1463
|
+
pendingDurationCalls.put(audioId, call);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
public void notifyDurationAvailable(String assetId, double duration) {
|
|
1467
|
+
logger.debug("Duration available for " + assetId + ": " + duration);
|
|
1468
|
+
PluginCall savedCall = pendingDurationCalls.remove(assetId);
|
|
1469
|
+
if (savedCall != null) {
|
|
1470
|
+
JSObject ret = new JSObject();
|
|
1471
|
+
ret.put("duration", duration);
|
|
1472
|
+
savedCall.resolve(ret);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private JSObject getAudioAssetData(String audioId) {
|
|
1477
|
+
JSObject data = audioData.get(audioId);
|
|
1478
|
+
if (data == null) {
|
|
1479
|
+
data = new JSObject();
|
|
1480
|
+
}
|
|
1481
|
+
return data;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private void setAudioAssetData(String audioId, JSObject data) {
|
|
1485
|
+
audioData.put(audioId, data);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
@PluginMethod
|
|
1489
|
+
public void getPluginVersion(final PluginCall call) {
|
|
1490
|
+
try {
|
|
1491
|
+
final JSObject ret = new JSObject();
|
|
1492
|
+
ret.put("version", this.pluginVersion);
|
|
1493
|
+
call.resolve(ret);
|
|
1494
|
+
} catch (final Exception e) {
|
|
1495
|
+
call.reject("Could not get plugin version", e);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
@PluginMethod
|
|
1500
|
+
public void deinitPlugin(final PluginCall call) {
|
|
1501
|
+
try {
|
|
1502
|
+
// Stop all playing audio
|
|
1503
|
+
if (audioAssetList != null) {
|
|
1504
|
+
for (AudioAsset asset : audioAssetList.values()) {
|
|
1505
|
+
if (asset != null) {
|
|
1506
|
+
asset.stop();
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Clear notification and release media session
|
|
1512
|
+
if (showNotification) {
|
|
1513
|
+
clearNotification();
|
|
1514
|
+
if (mediaSession != null) {
|
|
1515
|
+
mediaSession.release();
|
|
1516
|
+
mediaSession = null;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Release audio focus if we requested it
|
|
1521
|
+
if (audioFocusRequested && this.audioManager != null) {
|
|
1522
|
+
this.audioManager.abandonAudioFocus(this);
|
|
1523
|
+
audioFocusRequested = false;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Restore original audio mode if we changed it
|
|
1527
|
+
if (originalAudioMode != AudioManager.MODE_INVALID && this.audioManager != null) {
|
|
1528
|
+
this.audioManager.setMode(originalAudioMode);
|
|
1529
|
+
originalAudioMode = AudioManager.MODE_INVALID;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
call.resolve();
|
|
1533
|
+
} catch (Exception e) {
|
|
1534
|
+
Log.e(TAG, "Error in deinitPlugin", e);
|
|
1535
|
+
call.reject("Error deinitializing plugin: " + e.getMessage());
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Notification and MediaSession methods
|
|
1540
|
+
|
|
1541
|
+
static double clampSeekPositionSeconds(double currentPosition, double duration, double deltaSeconds) {
|
|
1542
|
+
double targetPosition = currentPosition + deltaSeconds;
|
|
1543
|
+
if (duration > 0) {
|
|
1544
|
+
return Math.max(0, Math.min(duration, targetPosition));
|
|
1545
|
+
}
|
|
1546
|
+
return Math.max(0, targetPosition);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
private void setupMediaSession() {
|
|
1550
|
+
if (mediaSession != null) return;
|
|
1551
|
+
|
|
1552
|
+
mediaSession = new MediaSessionCompat(getContext(), "NativeAudio");
|
|
1553
|
+
|
|
1554
|
+
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
|
|
1555
|
+
|
|
1556
|
+
PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder().setActions(
|
|
1557
|
+
PlaybackStateCompat.ACTION_PLAY |
|
|
1558
|
+
PlaybackStateCompat.ACTION_PAUSE |
|
|
1559
|
+
PlaybackStateCompat.ACTION_STOP |
|
|
1560
|
+
PlaybackStateCompat.ACTION_REWIND |
|
|
1561
|
+
PlaybackStateCompat.ACTION_FAST_FORWARD |
|
|
1562
|
+
PlaybackStateCompat.ACTION_SEEK_TO
|
|
1563
|
+
);
|
|
1564
|
+
mediaSession.setPlaybackState(stateBuilder.build());
|
|
1565
|
+
|
|
1566
|
+
// Set callback for media button events
|
|
1567
|
+
mediaSession.setCallback(
|
|
1568
|
+
new MediaSessionCompat.Callback() {
|
|
1569
|
+
@Override
|
|
1570
|
+
public void onPlay() {
|
|
1571
|
+
handleCurrentMediaAction("resuming", (audioId, asset) -> {
|
|
1572
|
+
if (!asset.isPlaying()) {
|
|
1573
|
+
asset.resume();
|
|
1574
|
+
}
|
|
1575
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PLAYING);
|
|
1576
|
+
updateNotification(audioId);
|
|
1577
|
+
notifyPlaybackState(audioId, "remotePlay");
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
@Override
|
|
1582
|
+
public void onPause() {
|
|
1583
|
+
handleCurrentMediaAction("pausing", (audioId, asset) -> {
|
|
1584
|
+
asset.pause();
|
|
1585
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_PAUSED);
|
|
1586
|
+
updateNotification(audioId);
|
|
1587
|
+
notifyPlaybackState(audioId, "remotePause");
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
@Override
|
|
1592
|
+
public void onStop() {
|
|
1593
|
+
String audioId = currentlyPlayingAssetId;
|
|
1594
|
+
runOnMainThread(() -> {
|
|
1595
|
+
if (!isStringValid(audioId)) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
try {
|
|
1599
|
+
stopAudio(audioId, false, 0);
|
|
1600
|
+
updateTrackedPlaybackState(audioId, PlaybackStateCompat.STATE_STOPPED);
|
|
1601
|
+
clearNotification();
|
|
1602
|
+
currentlyPlayingAssetId = null;
|
|
1603
|
+
notifyPlaybackState(audioId, "remoteStop");
|
|
1604
|
+
} catch (Exception e) {
|
|
1605
|
+
Log.e(TAG, "Error stopping audio from media session", e);
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
@Override
|
|
1611
|
+
public void onRewind() {
|
|
1612
|
+
handleCurrentMediaAction("rewinding", (audioId, asset) -> {
|
|
1613
|
+
double currentPosition = asset.getCurrentPosition();
|
|
1614
|
+
double duration = asset.getDuration();
|
|
1615
|
+
double newPosition = clampSeekPositionSeconds(currentPosition, duration, -NOTIFICATION_SKIP_SECONDS);
|
|
1616
|
+
asset.setCurrentPosition(newPosition);
|
|
1617
|
+
updatePlaybackState(resolvePlaybackState(audioId, asset), asset);
|
|
1618
|
+
notifyPlaybackState(audioId, "remoteRewind");
|
|
1619
|
+
Log.d(TAG, "Rewind 15s: " + currentPosition + " -> " + newPosition);
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
@Override
|
|
1624
|
+
public void onFastForward() {
|
|
1625
|
+
handleCurrentMediaAction("fast forwarding", (audioId, asset) -> {
|
|
1626
|
+
double currentPosition = asset.getCurrentPosition();
|
|
1627
|
+
double duration = asset.getDuration();
|
|
1628
|
+
double newPosition = clampSeekPositionSeconds(currentPosition, duration, NOTIFICATION_SKIP_SECONDS);
|
|
1629
|
+
asset.setCurrentPosition(newPosition);
|
|
1630
|
+
updatePlaybackState(resolvePlaybackState(audioId, asset), asset);
|
|
1631
|
+
notifyPlaybackState(audioId, "remoteFastForward");
|
|
1632
|
+
Log.d(TAG, "Fast forward 15s: " + currentPosition + " -> " + newPosition);
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
@Override
|
|
1637
|
+
public void onSeekTo(long pos) {
|
|
1638
|
+
handleCurrentMediaAction("seeking", (audioId, asset) -> {
|
|
1639
|
+
double duration = asset.getDuration();
|
|
1640
|
+
double positionInSeconds = clampSeekPositionSeconds(0, duration, pos / 1000.0);
|
|
1641
|
+
asset.setCurrentPosition(positionInSeconds);
|
|
1642
|
+
updatePlaybackState(resolvePlaybackState(audioId, asset), asset);
|
|
1643
|
+
notifyPlaybackState(audioId, "remoteSeek");
|
|
1644
|
+
Log.d(TAG, "Seek to: " + positionInSeconds);
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
);
|
|
1649
|
+
|
|
1650
|
+
mediaSession.setActive(true);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private void createNotificationChannel() {
|
|
1654
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1655
|
+
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Audio Playback", NotificationManager.IMPORTANCE_LOW);
|
|
1656
|
+
channel.setDescription("Shows currently playing audio");
|
|
1657
|
+
NotificationManager notificationManager = getContext().getSystemService(NotificationManager.class);
|
|
1658
|
+
if (notificationManager != null) {
|
|
1659
|
+
notificationManager.createNotificationChannel(channel);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private void updateNotification(String audioId) {
|
|
1665
|
+
if (mediaSession == null || !isStringValid(audioId)) return;
|
|
1666
|
+
|
|
1667
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1668
|
+
int playbackState = resolvePlaybackState(audioId, asset);
|
|
1669
|
+
|
|
1670
|
+
Map<String, String> metadata = notificationMetadataMap.get(audioId);
|
|
1671
|
+
String title = metadata != null && metadata.containsKey("title") ? metadata.get("title") : "Playing";
|
|
1672
|
+
String artist = metadata != null && metadata.containsKey("artist") ? metadata.get("artist") : "";
|
|
1673
|
+
String album = metadata != null && metadata.containsKey("album") ? metadata.get("album") : "";
|
|
1674
|
+
String artworkUrl = metadata != null ? metadata.get("artworkUrl") : null;
|
|
1675
|
+
|
|
1676
|
+
// Update MediaSession metadata
|
|
1677
|
+
MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder();
|
|
1678
|
+
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title);
|
|
1679
|
+
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist);
|
|
1680
|
+
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album);
|
|
1681
|
+
long durationMs = getPlaybackDurationMs(asset);
|
|
1682
|
+
if (durationMs > 0) {
|
|
1683
|
+
metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
updatePlaybackState(playbackState, asset);
|
|
1687
|
+
|
|
1688
|
+
// Load artwork if provided
|
|
1689
|
+
if (artworkUrl != null) {
|
|
1690
|
+
String targetAudioId = audioId;
|
|
1691
|
+
loadArtwork(artworkUrl, (bitmap) -> {
|
|
1692
|
+
if (mediaSession == null || !targetAudioId.equals(currentlyPlayingAssetId)) {
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
AudioAsset currentAsset = audioAssetList.get(targetAudioId);
|
|
1697
|
+
int currentPlaybackState = resolvePlaybackState(targetAudioId, currentAsset);
|
|
1698
|
+
if (bitmap != null) {
|
|
1699
|
+
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap);
|
|
1700
|
+
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap);
|
|
1701
|
+
}
|
|
1702
|
+
mediaSession.setMetadata(metadataBuilder.build());
|
|
1703
|
+
showNotification(title, artist, bitmap, currentPlaybackState == PlaybackStateCompat.STATE_PLAYING);
|
|
1704
|
+
});
|
|
1705
|
+
} else {
|
|
1706
|
+
mediaSession.setMetadata(metadataBuilder.build());
|
|
1707
|
+
showNotification(title, artist, null, playbackState == PlaybackStateCompat.STATE_PLAYING);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
private void showNotification(String title, String artist, Bitmap artwork, boolean isPlaying) {
|
|
1712
|
+
// Build notification with proper action order: Rewind, Play/Pause, Fast Forward
|
|
1713
|
+
// Use MediaButtonReceiver to properly wire actions to MediaSession callbacks
|
|
1714
|
+
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(getContext(), CHANNEL_ID)
|
|
1715
|
+
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
1716
|
+
.setContentTitle(title)
|
|
1717
|
+
.setContentText(artist)
|
|
1718
|
+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
1719
|
+
.setOngoing(isPlaying)
|
|
1720
|
+
// Add actions BEFORE setStyle() for proper wiring
|
|
1721
|
+
.addAction(
|
|
1722
|
+
new NotificationCompat.Action.Builder(
|
|
1723
|
+
android.R.drawable.ic_media_rew,
|
|
1724
|
+
"Rewind 15 seconds",
|
|
1725
|
+
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
1726
|
+
getContext(),
|
|
1727
|
+
PlaybackStateCompat.ACTION_REWIND
|
|
1728
|
+
)
|
|
1729
|
+
).build()
|
|
1730
|
+
)
|
|
1731
|
+
.addAction(
|
|
1732
|
+
new NotificationCompat.Action.Builder(
|
|
1733
|
+
isPlaying ? android.R.drawable.ic_media_pause : android.R.drawable.ic_media_play,
|
|
1734
|
+
isPlaying ? "Pause" : "Play",
|
|
1735
|
+
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
1736
|
+
getContext(),
|
|
1737
|
+
isPlaying ? PlaybackStateCompat.ACTION_PAUSE : PlaybackStateCompat.ACTION_PLAY
|
|
1738
|
+
)
|
|
1739
|
+
).build()
|
|
1740
|
+
)
|
|
1741
|
+
.addAction(
|
|
1742
|
+
new NotificationCompat.Action.Builder(
|
|
1743
|
+
android.R.drawable.ic_media_ff,
|
|
1744
|
+
"Fast forward 15 seconds",
|
|
1745
|
+
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
|
1746
|
+
getContext(),
|
|
1747
|
+
PlaybackStateCompat.ACTION_FAST_FORWARD
|
|
1748
|
+
)
|
|
1749
|
+
).build()
|
|
1750
|
+
)
|
|
1751
|
+
.setStyle(
|
|
1752
|
+
new androidx.media.app.NotificationCompat.MediaStyle()
|
|
1753
|
+
.setMediaSession(mediaSession.getSessionToken())
|
|
1754
|
+
.setShowActionsInCompactView(0, 1, 2)
|
|
1755
|
+
)
|
|
1756
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
1757
|
+
.setOnlyAlertOnce(true);
|
|
1758
|
+
|
|
1759
|
+
if (artwork != null) {
|
|
1760
|
+
notificationBuilder.setLargeIcon(artwork);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
PendingIntent contentIntent = getNotificationContentIntent();
|
|
1764
|
+
if (contentIntent != null) {
|
|
1765
|
+
notificationBuilder.setContentIntent(contentIntent);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(getContext());
|
|
1769
|
+
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
private void clearNotification() {
|
|
1773
|
+
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(getContext());
|
|
1774
|
+
notificationManager.cancel(NOTIFICATION_ID);
|
|
1775
|
+
|
|
1776
|
+
if (mediaSession != null) {
|
|
1777
|
+
updatePlaybackState(PlaybackStateCompat.STATE_STOPPED, null);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
private void updatePlaybackState(int state) {
|
|
1782
|
+
updatePlaybackState(state, getCurrentPlaybackAsset());
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
private void updatePlaybackState(int state, AudioAsset asset) {
|
|
1786
|
+
if (mediaSession == null) return;
|
|
1787
|
+
|
|
1788
|
+
long positionMs = getPlaybackPositionMs(asset);
|
|
1789
|
+
PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder()
|
|
1790
|
+
.setState(state, positionMs, state == PlaybackStateCompat.STATE_PLAYING ? 1.0f : 0.0f, SystemClock.elapsedRealtime())
|
|
1791
|
+
.setActions(
|
|
1792
|
+
PlaybackStateCompat.ACTION_PLAY |
|
|
1793
|
+
PlaybackStateCompat.ACTION_PAUSE |
|
|
1794
|
+
PlaybackStateCompat.ACTION_STOP |
|
|
1795
|
+
PlaybackStateCompat.ACTION_REWIND |
|
|
1796
|
+
PlaybackStateCompat.ACTION_FAST_FORWARD |
|
|
1797
|
+
PlaybackStateCompat.ACTION_SEEK_TO
|
|
1798
|
+
);
|
|
1799
|
+
mediaSession.setPlaybackState(stateBuilder.build());
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
private void syncCurrentPlaybackState(String reason) {
|
|
1803
|
+
if (!isStringValid(currentlyPlayingAssetId)) {
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
updateTrackedPlaybackState(currentlyPlayingAssetId, resolvePlaybackState(currentlyPlayingAssetId, getCurrentPlaybackAsset()));
|
|
1807
|
+
if (showNotification) {
|
|
1808
|
+
updateNotification(currentlyPlayingAssetId);
|
|
1809
|
+
}
|
|
1810
|
+
notifyPlaybackState(currentlyPlayingAssetId, reason);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
private void handleCurrentMediaAction(String verb, MediaSessionAction action) {
|
|
1814
|
+
String audioId = currentlyPlayingAssetId;
|
|
1815
|
+
runOnMainThread(() -> {
|
|
1816
|
+
if (!isStringValid(audioId) || !audioAssetList.containsKey(audioId)) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1821
|
+
if (asset == null) {
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
try {
|
|
1826
|
+
action.run(audioId, asset);
|
|
1827
|
+
} catch (Exception e) {
|
|
1828
|
+
Log.e(TAG, "Error " + verb + " audio from media session", e);
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
private void runOnMainThread(Runnable action) {
|
|
1834
|
+
if (getActivity() != null) {
|
|
1835
|
+
getActivity().runOnUiThread(action);
|
|
1836
|
+
} else {
|
|
1837
|
+
new Handler(Looper.getMainLooper()).post(action);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
private AudioAsset getCurrentPlaybackAsset() {
|
|
1842
|
+
if (!isStringValid(currentlyPlayingAssetId)) {
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
return audioAssetList.get(currentlyPlayingAssetId);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
private int resolvePlaybackState(String audioId, AudioAsset asset) {
|
|
1849
|
+
if (!isStringValid(audioId)) {
|
|
1850
|
+
return PlaybackStateCompat.STATE_STOPPED;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (asset != null) {
|
|
1854
|
+
try {
|
|
1855
|
+
if (asset.isPlaying()) {
|
|
1856
|
+
return PlaybackStateCompat.STATE_PLAYING;
|
|
1857
|
+
}
|
|
1858
|
+
} catch (Exception e) {
|
|
1859
|
+
Log.e(TAG, "Error resolving playback state", e);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
Integer trackedState = playbackStateByAssetId.get(audioId);
|
|
1864
|
+
if (trackedState != null) {
|
|
1865
|
+
return trackedState;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (audioId.equals(currentlyPlayingAssetId)) {
|
|
1869
|
+
return PlaybackStateCompat.STATE_PAUSED;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
return PlaybackStateCompat.STATE_STOPPED;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
private long getPlaybackPositionMs(AudioAsset asset) {
|
|
1876
|
+
if (asset == null) {
|
|
1877
|
+
return 0;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
try {
|
|
1881
|
+
return Math.max(0, Math.round(asset.getCurrentPosition() * 1000));
|
|
1882
|
+
} catch (Exception e) {
|
|
1883
|
+
Log.e(TAG, "Error reading playback position", e);
|
|
1884
|
+
return 0;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
private long getPlaybackDurationMs(AudioAsset asset) {
|
|
1889
|
+
if (asset == null) {
|
|
1890
|
+
return 0;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
try {
|
|
1894
|
+
return Math.max(0, Math.round(asset.getDuration() * 1000));
|
|
1895
|
+
} catch (Exception e) {
|
|
1896
|
+
Log.e(TAG, "Error reading playback duration", e);
|
|
1897
|
+
return 0;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
private void notifyPlaybackState(String audioId, String reason) {
|
|
1902
|
+
if (!isStringValid(audioId)) {
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
AudioAsset asset = audioAssetList.get(audioId);
|
|
1907
|
+
int playbackState = resolvePlaybackState(audioId, asset);
|
|
1908
|
+
|
|
1909
|
+
JSObject ret = new JSObject();
|
|
1910
|
+
ret.put("assetId", audioId);
|
|
1911
|
+
ret.put("state", playbackStateToString(playbackState));
|
|
1912
|
+
ret.put("reason", reason);
|
|
1913
|
+
ret.put("isPlaying", playbackState == PlaybackStateCompat.STATE_PLAYING);
|
|
1914
|
+
|
|
1915
|
+
if (asset != null) {
|
|
1916
|
+
ret.put("currentTime", getPlaybackPositionMs(asset) / 1000.0);
|
|
1917
|
+
ret.put("duration", getPlaybackDurationMs(asset) / 1000.0);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
notifyListeners("playbackState", ret);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
private void updateTrackedPlaybackState(String audioId, int playbackState) {
|
|
1924
|
+
if (!isStringValid(audioId)) {
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
playbackStateByAssetId.put(audioId, playbackState);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
private void clearTrackedPlaybackState(String audioId) {
|
|
1931
|
+
if (!isStringValid(audioId)) {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
playbackStateByAssetId.remove(audioId);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
private String playbackStateToString(int playbackState) {
|
|
1938
|
+
if (playbackState == PlaybackStateCompat.STATE_PLAYING) {
|
|
1939
|
+
return "playing";
|
|
1940
|
+
}
|
|
1941
|
+
if (playbackState == PlaybackStateCompat.STATE_PAUSED) {
|
|
1942
|
+
return "paused";
|
|
1943
|
+
}
|
|
1944
|
+
return "stopped";
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
private PendingIntent getNotificationContentIntent() {
|
|
1948
|
+
Intent launchIntent = getContext().getPackageManager().getLaunchIntentForPackage(getContext().getPackageName());
|
|
1949
|
+
if (launchIntent == null) {
|
|
1950
|
+
return null;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
|
1954
|
+
|
|
1955
|
+
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
|
1956
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
1957
|
+
flags |= PendingIntent.FLAG_IMMUTABLE;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
return PendingIntent.getActivity(getContext(), NOTIFICATION_ID, launchIntent, flags);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
private void loadArtwork(String urlString, ArtworkCallback callback) {
|
|
1964
|
+
new Thread(() -> {
|
|
1965
|
+
try {
|
|
1966
|
+
Uri uri = Uri.parse(urlString);
|
|
1967
|
+
Bitmap bitmap = null;
|
|
1968
|
+
|
|
1969
|
+
// Configure BitmapFactory options to decode at full resolution
|
|
1970
|
+
BitmapFactory.Options options = new BitmapFactory.Options();
|
|
1971
|
+
options.inScaled = false; // Disable density-based scaling
|
|
1972
|
+
options.inPreferredConfig = Bitmap.Config.ARGB_8888; // Use high quality format
|
|
1973
|
+
|
|
1974
|
+
if (uri.getScheme() == null || uri.getScheme().equals("file")) {
|
|
1975
|
+
// Local file
|
|
1976
|
+
File file = new File(uri.getPath());
|
|
1977
|
+
if (file.exists()) {
|
|
1978
|
+
bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
|
|
1979
|
+
}
|
|
1980
|
+
} else {
|
|
1981
|
+
// Remote URL
|
|
1982
|
+
URL url = new URL(urlString);
|
|
1983
|
+
bitmap = BitmapFactory.decodeStream(url.openConnection().getInputStream(), null, options);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Resize to optimal notification size if the bitmap is too large
|
|
1987
|
+
// Android notifications typically display artwork at around 128-256dp
|
|
1988
|
+
// We target 512px as a good balance between quality and memory usage
|
|
1989
|
+
if (bitmap != null && bitmap.getWidth() > 0 && bitmap.getHeight() > 0) {
|
|
1990
|
+
if (bitmap.getWidth() > MAX_NOTIFICATION_ARTWORK_SIZE || bitmap.getHeight() > MAX_NOTIFICATION_ARTWORK_SIZE) {
|
|
1991
|
+
float scale = Math.min(
|
|
1992
|
+
(float) MAX_NOTIFICATION_ARTWORK_SIZE / bitmap.getWidth(),
|
|
1993
|
+
(float) MAX_NOTIFICATION_ARTWORK_SIZE / bitmap.getHeight()
|
|
1994
|
+
);
|
|
1995
|
+
int newWidth = Math.round(bitmap.getWidth() * scale);
|
|
1996
|
+
int newHeight = Math.round(bitmap.getHeight() * scale);
|
|
1997
|
+
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true);
|
|
1998
|
+
if (scaledBitmap != null) {
|
|
1999
|
+
bitmap.recycle(); // Free memory from original bitmap
|
|
2000
|
+
bitmap = scaledBitmap;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
Bitmap finalBitmap = bitmap;
|
|
2006
|
+
new Handler(Looper.getMainLooper()).post(() -> callback.onArtworkLoaded(finalBitmap));
|
|
2007
|
+
} catch (Exception e) {
|
|
2008
|
+
Log.e(TAG, "Error loading artwork", e);
|
|
2009
|
+
new Handler(Looper.getMainLooper()).post(() -> callback.onArtworkLoaded(null));
|
|
2010
|
+
}
|
|
2011
|
+
})
|
|
2012
|
+
.start();
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
interface ArtworkCallback {
|
|
2016
|
+
void onArtworkLoaded(Bitmap bitmap);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
private interface MediaSessionAction {
|
|
2020
|
+
void run(String audioId, AudioAsset asset) throws Exception;
|
|
2021
|
+
}
|
|
2022
|
+
}
|