@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.
Files changed (46) hide show
  1. package/CapgoCapacitorNativeAudio.podspec +16 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +31 -0
  4. package/README.md +1229 -0
  5. package/android/build.gradle +89 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/java/ee/forgr/audio/AudioAsset.java +611 -0
  8. package/android/src/main/java/ee/forgr/audio/AudioCompletionListener.java +5 -0
  9. package/android/src/main/java/ee/forgr/audio/AudioDispatcher.java +208 -0
  10. package/android/src/main/java/ee/forgr/audio/Constant.java +36 -0
  11. package/android/src/main/java/ee/forgr/audio/HlsAvailabilityChecker.java +84 -0
  12. package/android/src/main/java/ee/forgr/audio/Logger.java +55 -0
  13. package/android/src/main/java/ee/forgr/audio/NativeAudio.java +2022 -0
  14. package/android/src/main/java/ee/forgr/audio/RemoteAudioAsset.java +886 -0
  15. package/android/src/main/java/ee/forgr/audio/StreamAudioAsset.java +708 -0
  16. package/android/src/main/res/values/colors.xml +3 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/android/src/main/res/values/styles.xml +3 -0
  19. package/dist/docs.json +1470 -0
  20. package/dist/esm/audio-asset.d.ts +4 -0
  21. package/dist/esm/audio-asset.js +6 -0
  22. package/dist/esm/audio-asset.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +597 -0
  24. package/dist/esm/definitions.js +2 -0
  25. package/dist/esm/definitions.js.map +1 -0
  26. package/dist/esm/index.d.ts +4 -0
  27. package/dist/esm/index.js +7 -0
  28. package/dist/esm/index.js.map +1 -0
  29. package/dist/esm/web.d.ts +82 -0
  30. package/dist/esm/web.js +553 -0
  31. package/dist/esm/web.js.map +1 -0
  32. package/dist/plugin.cjs.js +571 -0
  33. package/dist/plugin.cjs.js.map +1 -0
  34. package/dist/plugin.js +574 -0
  35. package/dist/plugin.js.map +1 -0
  36. package/ios/Sources/NativeAudioPlugin/AudioAsset+Fade.swift +157 -0
  37. package/ios/Sources/NativeAudioPlugin/AudioAsset.swift +403 -0
  38. package/ios/Sources/NativeAudioPlugin/Constant.swift +52 -0
  39. package/ios/Sources/NativeAudioPlugin/Logger.swift +43 -0
  40. package/ios/Sources/NativeAudioPlugin/Plugin.swift +1786 -0
  41. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset+Fade.swift +152 -0
  42. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset.swift +405 -0
  43. package/ios/Tests/NativeAudioPluginTests/PluginTests.swift +648 -0
  44. package/ios/Tests/README.md +39 -0
  45. package/package.json +101 -0
  46. package/scripts/configure-dependencies.js +251 -0
@@ -0,0 +1,886 @@
1
+ package ee.forgr.audio;
2
+
3
+ import android.content.Context;
4
+ import android.net.Uri;
5
+ import android.os.Handler;
6
+ import android.os.Looper;
7
+ import android.util.Log;
8
+ import androidx.media3.common.MediaItem;
9
+ import androidx.media3.common.Player;
10
+ import androidx.media3.common.util.UnstableApi;
11
+ import androidx.media3.database.StandaloneDatabaseProvider;
12
+ import androidx.media3.datasource.DefaultHttpDataSource;
13
+ import androidx.media3.datasource.cache.CacheDataSource;
14
+ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor;
15
+ import androidx.media3.datasource.cache.SimpleCache;
16
+ import androidx.media3.exoplayer.ExoPlayer;
17
+ import androidx.media3.exoplayer.source.MediaSource;
18
+ import androidx.media3.exoplayer.source.ProgressiveMediaSource;
19
+ import java.io.File;
20
+ import java.util.ArrayList;
21
+ import java.util.Map;
22
+ import java.util.concurrent.TimeUnit;
23
+
24
+ @UnstableApi
25
+ public class RemoteAudioAsset extends AudioAsset {
26
+
27
+ private static final String TAG = "RemoteAudioAsset";
28
+ private final ArrayList<ExoPlayer> players;
29
+ private int playIndex = 0;
30
+ private final Uri uri;
31
+ private float volume;
32
+ private boolean isPrepared = false;
33
+ private static SimpleCache cache;
34
+ private static final long MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100MB cache
35
+ protected AudioCompletionListener completionListener;
36
+ private static final float FADE_STEP = 0.05f;
37
+ private static final int FADE_DELAY_MS = 80; // 80ms between steps
38
+ private float initialVolume;
39
+ private Handler currentTimeHandler;
40
+ private Runnable currentTimeRunnable;
41
+ private final Map<String, String> headers;
42
+
43
+ public RemoteAudioAsset(NativeAudio owner, String assetId, Uri uri, int audioChannelNum, float volume, Map<String, String> headers)
44
+ throws Exception {
45
+ super(owner, assetId, null, 0, volume);
46
+ this.uri = uri;
47
+ this.volume = volume;
48
+ this.initialVolume = volume;
49
+ this.players = new ArrayList<>();
50
+ this.headers = headers;
51
+
52
+ if (audioChannelNum < 1) {
53
+ audioChannelNum = 1;
54
+ }
55
+
56
+ final int channels = audioChannelNum;
57
+ owner
58
+ .getActivity()
59
+ .runOnUiThread(
60
+ new Runnable() {
61
+ @Override
62
+ public void run() {
63
+ try {
64
+ for (int i = 0; i < channels; i++) {
65
+ ExoPlayer player = new ExoPlayer.Builder(owner.getContext()).build();
66
+ player.setPlaybackSpeed(1.0f);
67
+ players.add(player);
68
+ initializePlayer(player);
69
+ }
70
+ } catch (Exception e) {
71
+ logger.error("Error initializing players", e);
72
+ }
73
+ }
74
+ }
75
+ );
76
+ }
77
+
78
+ @UnstableApi
79
+ private void initializePlayer(ExoPlayer player) {
80
+ logger.debug("Initializing player");
81
+
82
+ // Initialize cache if not already done
83
+ if (cache == null) {
84
+ File cacheDir = new File(owner.getContext().getCacheDir(), "media");
85
+ if (!cacheDir.exists()) {
86
+ cacheDir.mkdirs();
87
+ }
88
+ cache = new SimpleCache(
89
+ cacheDir,
90
+ new LeastRecentlyUsedCacheEvictor(MAX_CACHE_SIZE),
91
+ new StandaloneDatabaseProvider(owner.getContext())
92
+ );
93
+ }
94
+
95
+ // Create cached data source factory with custom headers
96
+ DefaultHttpDataSource.Factory httpDataSourceFactory = new DefaultHttpDataSource.Factory()
97
+ .setAllowCrossProtocolRedirects(true)
98
+ .setConnectTimeoutMs(15000)
99
+ .setReadTimeoutMs(15000);
100
+
101
+ // Add custom headers if provided
102
+ if (headers != null && !headers.isEmpty()) {
103
+ httpDataSourceFactory.setDefaultRequestProperties(headers);
104
+ }
105
+
106
+ CacheDataSource.Factory cacheDataSourceFactory = new CacheDataSource.Factory()
107
+ .setCache(cache)
108
+ .setUpstreamDataSourceFactory(httpDataSourceFactory)
109
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR);
110
+
111
+ // Create media source
112
+ MediaSource mediaSource = new ProgressiveMediaSource.Factory(cacheDataSourceFactory).createMediaSource(MediaItem.fromUri(uri));
113
+
114
+ player.setMediaSource(mediaSource);
115
+ player.setVolume(volume);
116
+ player.prepare();
117
+
118
+ // Add listener for duration
119
+ player.addListener(
120
+ new Player.Listener() {
121
+ @Override
122
+ public void onPlaybackStateChanged(int playbackState) {
123
+ Log.d(TAG, "Player state changed to: " + getStateString(playbackState));
124
+ if (playbackState == Player.STATE_READY) {
125
+ isPrepared = true;
126
+ long duration = player.getDuration();
127
+ Log.d(TAG, "Duration available on STATE_READY: " + duration + " ms");
128
+ if (duration != androidx.media3.common.C.TIME_UNSET) {
129
+ double durationSec = duration / 1000.0;
130
+ Log.d(TAG, "Notifying duration: " + durationSec + " seconds");
131
+ owner.notifyDurationAvailable(assetId, durationSec);
132
+ }
133
+ } else if (playbackState == Player.STATE_ENDED) {
134
+ notifyCompletion();
135
+ }
136
+ }
137
+
138
+ @Override
139
+ public void onIsPlayingChanged(boolean isPlaying) {
140
+ logger.debug("isPlaying changed to: " + isPlaying + ", state: " + getStateString(player.getPlaybackState()));
141
+ }
142
+
143
+ @Override
144
+ public void onIsLoadingChanged(boolean isLoading) {
145
+ logger.debug("isLoading changed to: " + isLoading + ", state: " + getStateString(player.getPlaybackState()));
146
+ }
147
+ }
148
+ );
149
+
150
+ logger.debug("Player initialization complete");
151
+ }
152
+
153
+ private String getStateString(int state) {
154
+ switch (state) {
155
+ case Player.STATE_IDLE:
156
+ return "IDLE";
157
+ case Player.STATE_BUFFERING:
158
+ return "BUFFERING";
159
+ case Player.STATE_READY:
160
+ return "READY";
161
+ case Player.STATE_ENDED:
162
+ return "ENDED";
163
+ default:
164
+ return "UNKNOWN(" + state + ")";
165
+ }
166
+ }
167
+
168
+ @Override
169
+ public void play(double time, float volume) throws Exception {
170
+ if (players.isEmpty()) {
171
+ throw new Exception("No ExoPlayer available");
172
+ }
173
+
174
+ final ExoPlayer player = players.get(playIndex);
175
+ owner
176
+ .getActivity()
177
+ .runOnUiThread(
178
+ new Runnable() {
179
+ @Override
180
+ public void run() {
181
+ if (!isPrepared) {
182
+ player.addListener(
183
+ new Player.Listener() {
184
+ @Override
185
+ public void onPlaybackStateChanged(int playbackState) {
186
+ if (playbackState == Player.STATE_READY) {
187
+ isPrepared = true;
188
+ try {
189
+ playInternal(player, time, volume);
190
+ startCurrentTimeUpdates();
191
+ } catch (Exception e) {
192
+ Log.e(TAG, "Error playing after prepare", e);
193
+ }
194
+ } else if (playbackState == Player.STATE_ENDED) {
195
+ notifyCompletion();
196
+ }
197
+ }
198
+ }
199
+ );
200
+ } else {
201
+ try {
202
+ playInternal(player, time, volume);
203
+ startCurrentTimeUpdates();
204
+ } catch (Exception e) {
205
+ logger.error("Error playing", e);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ );
211
+
212
+ playIndex = (playIndex + 1) % players.size();
213
+ }
214
+
215
+ private void playInternal(final ExoPlayer player, final double time, final float volume) throws Exception {
216
+ owner
217
+ .getActivity()
218
+ .runOnUiThread(
219
+ new Runnable() {
220
+ @Override
221
+ public void run() {
222
+ if (time != 0) {
223
+ player.seekTo(Math.round(time * 1000));
224
+ }
225
+ if (volume != 0) {
226
+ player.setVolume(volume);
227
+ }
228
+ player.play();
229
+ }
230
+ }
231
+ );
232
+ }
233
+
234
+ @Override
235
+ public boolean pause() throws Exception {
236
+ final boolean[] wasPlaying = { false };
237
+ owner
238
+ .getActivity()
239
+ .runOnUiThread(
240
+ new Runnable() {
241
+ @Override
242
+ public void run() {
243
+ cancelFade();
244
+ for (ExoPlayer player : players) {
245
+ if (player != null && player.isPlaying()) {
246
+ player.pause();
247
+ stopCurrentTimeUpdates();
248
+ wasPlaying[0] = true;
249
+ }
250
+ }
251
+ }
252
+ }
253
+ );
254
+ return wasPlaying[0];
255
+ }
256
+
257
+ @Override
258
+ public void resume() throws Exception {
259
+ owner
260
+ .getActivity()
261
+ .runOnUiThread(
262
+ new Runnable() {
263
+ @Override
264
+ public void run() {
265
+ for (ExoPlayer player : players) {
266
+ if (player != null && !player.isPlaying()) {
267
+ player.play();
268
+ }
269
+ }
270
+ startCurrentTimeUpdates();
271
+ }
272
+ }
273
+ );
274
+ }
275
+
276
+ @Override
277
+ public void stop() throws Exception {
278
+ owner
279
+ .getActivity()
280
+ .runOnUiThread(
281
+ new Runnable() {
282
+ @Override
283
+ public void run() {
284
+ cancelFade();
285
+ for (ExoPlayer player : players) {
286
+ if (player != null && player.isPlaying()) {
287
+ player.stop();
288
+ dispatchComplete();
289
+ }
290
+ // Reset the ExoPlayer to make it ready for future playback
291
+ initializePlayer(player);
292
+ }
293
+ isPrepared = false;
294
+ }
295
+ }
296
+ );
297
+ }
298
+
299
+ @Override
300
+ public void loop() throws Exception {
301
+ owner
302
+ .getActivity()
303
+ .runOnUiThread(
304
+ new Runnable() {
305
+ @Override
306
+ public void run() {
307
+ if (!players.isEmpty()) {
308
+ ExoPlayer player = players.get(playIndex);
309
+ player.setRepeatMode(Player.REPEAT_MODE_ONE);
310
+ player.play();
311
+ playIndex = (playIndex + 1) % players.size();
312
+ startCurrentTimeUpdates();
313
+ }
314
+ }
315
+ }
316
+ );
317
+ }
318
+
319
+ @Override
320
+ public void unload() throws Exception {
321
+ if (Looper.myLooper() == Looper.getMainLooper()) {
322
+ // Synchronous cleanup when already on the main thread
323
+ stopCurrentTimeUpdates();
324
+ for (ExoPlayer player : new ArrayList<>(players)) {
325
+ try {
326
+ player.release();
327
+ } catch (Exception e) {
328
+ Log.w(TAG, "Error releasing player", e);
329
+ }
330
+ }
331
+ players.clear();
332
+ isPrepared = false;
333
+ playIndex = 0;
334
+ return;
335
+ }
336
+ // Ensure cleanup completes before returning when called off the main thread
337
+ final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
338
+ new Handler(Looper.getMainLooper()).post(() -> {
339
+ try {
340
+ stopCurrentTimeUpdates();
341
+ for (ExoPlayer player : new ArrayList<>(players)) {
342
+ try {
343
+ player.release();
344
+ } catch (Exception e) {
345
+ Log.w(TAG, "Error releasing player", e);
346
+ }
347
+ }
348
+ players.clear();
349
+ isPrepared = false;
350
+ playIndex = 0;
351
+ } finally {
352
+ latch.countDown();
353
+ }
354
+ });
355
+ try {
356
+ // Don't block forever; adjust timeout as needed
357
+ latch.await(2, java.util.concurrent.TimeUnit.SECONDS);
358
+ } catch (InterruptedException ie) {
359
+ Thread.currentThread().interrupt();
360
+ }
361
+ }
362
+
363
+ @Override
364
+ public void setVolume(final float volume, final double duration) throws Exception {
365
+ this.volume = volume;
366
+ owner
367
+ .getActivity()
368
+ .runOnUiThread(
369
+ new Runnable() {
370
+ @Override
371
+ public void run() {
372
+ cancelFade();
373
+ for (ExoPlayer player : players) {
374
+ if (player == null) continue;
375
+ if (player.isPlaying() && duration > 0) {
376
+ fadeTo(player, (float) duration, volume);
377
+ } else {
378
+ player.setVolume(volume);
379
+ }
380
+ }
381
+ }
382
+ }
383
+ );
384
+ }
385
+
386
+ @Override
387
+ public void setVolume(final float volume) throws Exception {
388
+ setVolume(volume, 0);
389
+ }
390
+
391
+ @Override
392
+ public float getVolume() throws Exception {
393
+ if (players.isEmpty()) {
394
+ throw new Exception("No ExoPlayer available");
395
+ }
396
+
397
+ final ExoPlayer player = players.get(playIndex);
398
+ return player != null ? player.getVolume() : 0;
399
+ }
400
+
401
+ @Override
402
+ public boolean isPlaying() throws Exception {
403
+ if (players.isEmpty() || !isPrepared) return false;
404
+
405
+ ExoPlayer player = players.get(playIndex);
406
+ return player != null && player.isPlaying();
407
+ }
408
+
409
+ @Override
410
+ public double getDuration() {
411
+ logger.debug("getDuration called, players empty: " + players.isEmpty() + ", isPrepared: " + isPrepared);
412
+ if (!players.isEmpty() && isPrepared) {
413
+ final double[] duration = { 0 };
414
+ owner
415
+ .getActivity()
416
+ .runOnUiThread(
417
+ new Runnable() {
418
+ @Override
419
+ public void run() {
420
+ ExoPlayer player = players.get(playIndex);
421
+ int state = player.getPlaybackState();
422
+ logger.debug("Player state: " + state + " (READY=" + Player.STATE_READY + ")");
423
+ if (state == Player.STATE_READY) {
424
+ long rawDuration = player.getDuration();
425
+ logger.debug("Raw duration: " + rawDuration + ", TIME_UNSET=" + androidx.media3.common.C.TIME_UNSET);
426
+ if (rawDuration != androidx.media3.common.C.TIME_UNSET) {
427
+ duration[0] = rawDuration / 1000.0;
428
+ logger.debug("Final duration in seconds: " + duration[0]);
429
+ } else {
430
+ logger.debug("Duration is TIME_UNSET");
431
+ }
432
+ } else {
433
+ logger.debug("Player not in READY state");
434
+ }
435
+ }
436
+ }
437
+ );
438
+ return duration[0];
439
+ }
440
+ logger.debug("No players or not prepared for duration");
441
+ return 0;
442
+ }
443
+
444
+ @Override
445
+ public double getCurrentPosition() {
446
+ if (!players.isEmpty() && isPrepared) {
447
+ final double[] position = { 0 };
448
+ owner
449
+ .getActivity()
450
+ .runOnUiThread(
451
+ new Runnable() {
452
+ @Override
453
+ public void run() {
454
+ ExoPlayer player = players.get(playIndex);
455
+ if (player.getPlaybackState() == Player.STATE_READY) {
456
+ long rawPosition = player.getCurrentPosition();
457
+ logger.debug("Raw position: " + rawPosition);
458
+ position[0] = rawPosition / 1000.0;
459
+ }
460
+ }
461
+ }
462
+ );
463
+ return position[0];
464
+ }
465
+ return 0;
466
+ }
467
+
468
+ @Override
469
+ public void setCurrentTime(double time) throws Exception {
470
+ if (players.isEmpty()) {
471
+ throw new Exception("No ExoPlayer available");
472
+ }
473
+
474
+ final ExoPlayer player = players.get(playIndex);
475
+ owner
476
+ .getActivity()
477
+ .runOnUiThread(
478
+ new Runnable() {
479
+ @Override
480
+ public void run() {
481
+ if (isPrepared) {
482
+ player.seekTo(Math.round(time * 1000));
483
+ } else {
484
+ player.addListener(
485
+ new Player.Listener() {
486
+ @Override
487
+ public void onPlaybackStateChanged(int playbackState) {
488
+ if (playbackState == Player.STATE_READY) {
489
+ isPrepared = true;
490
+ player.seekTo(Math.round(time * 1000));
491
+ }
492
+ }
493
+ }
494
+ );
495
+ }
496
+ }
497
+ }
498
+ );
499
+ }
500
+
501
+ @UnstableApi
502
+ public static void clearCache(Context context) {
503
+ try {
504
+ if (cache != null) {
505
+ cache.release();
506
+ cache = null;
507
+ }
508
+ File cacheDir = new File(context.getCacheDir(), "media");
509
+ if (cacheDir.exists()) {
510
+ deleteDir(cacheDir);
511
+ }
512
+ } catch (Exception e) {
513
+ logger.error("Error clearing audio cache", e);
514
+ }
515
+ }
516
+
517
+ private static boolean deleteDir(File dir) {
518
+ if (dir.isDirectory()) {
519
+ String[] children = dir.list();
520
+ if (children != null) {
521
+ for (String child : children) {
522
+ boolean success = deleteDir(new File(dir, child));
523
+ if (!success) {
524
+ return false;
525
+ }
526
+ }
527
+ }
528
+ }
529
+ return dir.delete();
530
+ }
531
+
532
+ public void playWithFadeIn(double time, float volume, float fadeInDurationMs) throws Exception {
533
+ if (players.isEmpty()) {
534
+ throw new Exception("No ExoPlayer available");
535
+ }
536
+
537
+ final ExoPlayer player = players.get(playIndex);
538
+ owner
539
+ .getActivity()
540
+ .runOnUiThread(
541
+ new Runnable() {
542
+ @Override
543
+ public void run() {
544
+ if (player != null && !player.isPlaying()) {
545
+ if (time != 0) {
546
+ player.seekTo(Math.round(time * 1000));
547
+ }
548
+ player.setVolume(0);
549
+ player.play();
550
+ startCurrentTimeUpdates();
551
+ fadeIn(player, fadeInDurationMs, volume);
552
+ }
553
+ }
554
+ }
555
+ );
556
+ }
557
+
558
+ private void fadeIn(final ExoPlayer player, float fadeInDurationMs, float volume) {
559
+ cancelFade();
560
+ fadeState = FadeState.FADE_IN;
561
+
562
+ final float targetVolume = volume;
563
+ final int steps = Math.max(1, (int) (fadeInDurationMs / FADE_DELAY_MS));
564
+ final float fadeStep = targetVolume / steps;
565
+
566
+ Log.d(
567
+ TAG,
568
+ "Beginning fade in at time " +
569
+ getCurrentPosition() +
570
+ " over " +
571
+ (fadeInDurationMs / 1000.0) +
572
+ "s to target volume " +
573
+ targetVolume +
574
+ " in " +
575
+ steps +
576
+ " steps (step duration: " +
577
+ (FADE_DELAY_MS / 1000.0) +
578
+ "s"
579
+ );
580
+
581
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
582
+ new Runnable() {
583
+ float currentVolume = 0;
584
+
585
+ @Override
586
+ public void run() {
587
+ if (fadeState != FadeState.FADE_IN || currentVolume >= targetVolume) {
588
+ fadeState = FadeState.NONE;
589
+ cancelFade();
590
+ logger.debug("Fade in complete at time " + getCurrentPosition());
591
+ return;
592
+ }
593
+ final float previousCurrentVolume = currentVolume;
594
+ currentVolume += fadeStep;
595
+ final float resolvedTargetVolume = Math.min(currentVolume, targetVolume);
596
+ Log.v(
597
+ TAG,
598
+ "Fade in step: from " + previousCurrentVolume + " to " + currentVolume + " to target " + resolvedTargetVolume
599
+ );
600
+ owner
601
+ .getActivity()
602
+ .runOnUiThread(() -> {
603
+ if (player != null && player.isPlaying()) {
604
+ player.setVolume(currentVolume);
605
+ }
606
+ });
607
+ }
608
+ },
609
+ 0,
610
+ FADE_DELAY_MS,
611
+ TimeUnit.MILLISECONDS
612
+ );
613
+ }
614
+
615
+ @Override
616
+ public void stopWithFade(double fadeOutDurationMs, boolean toPause) throws Exception {
617
+ stopWithFade((float) fadeOutDurationMs, toPause);
618
+ }
619
+
620
+ public void stopWithFade(float fadeOutDurationMs, boolean asPause) throws Exception {
621
+ if (players.isEmpty()) {
622
+ if (!asPause) {
623
+ stop();
624
+ }
625
+ return;
626
+ }
627
+
628
+ final ExoPlayer player = players.get(playIndex);
629
+ owner
630
+ .getActivity()
631
+ .runOnUiThread(() -> {
632
+ if (player != null && player.isPlaying()) {
633
+ fadeOut(player, fadeOutDurationMs, asPause);
634
+ } else if (!asPause) {
635
+ try {
636
+ stop();
637
+ } catch (Exception e) {
638
+ logger.error("Error stopping remote audio after failed fade", e);
639
+ }
640
+ }
641
+ });
642
+ }
643
+
644
+ private void fadeOut(final ExoPlayer player, float fadeOutDurationMs, boolean asPause) {
645
+ cancelFade();
646
+ fadeState = FadeState.FADE_OUT;
647
+
648
+ final int steps = Math.max(1, (int) (fadeOutDurationMs / FADE_DELAY_MS));
649
+ final float initialVolume = player.getVolume();
650
+ final float fadeStep = initialVolume / steps;
651
+
652
+ Log.d(
653
+ TAG,
654
+ "Beginning fade out from volume " +
655
+ initialVolume +
656
+ " at time " +
657
+ getCurrentPosition() +
658
+ " over " +
659
+ (fadeOutDurationMs / 1000.0) +
660
+ "s in " +
661
+ steps +
662
+ " steps (step duration: " +
663
+ (FADE_DELAY_MS / 1000.0) +
664
+ "s)"
665
+ );
666
+
667
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
668
+ new Runnable() {
669
+ float currentVolume = initialVolume;
670
+
671
+ @Override
672
+ public void run() {
673
+ if (fadeState != FadeState.FADE_OUT || currentVolume <= 0) {
674
+ fadeState = FadeState.NONE;
675
+ owner
676
+ .getActivity()
677
+ .runOnUiThread(() -> {
678
+ // Stop/pause unconditionally: the fade brought volume to zero so the
679
+ // player must be stopped regardless of its current isPlaying() state
680
+ // (e.g. ExoPlayer may have auto-stopped at volume 0 on some devices).
681
+ if (player != null) {
682
+ if (asPause) {
683
+ player.pause();
684
+ logger.verbose("Faded out to pause at time " + getCurrentPosition());
685
+ } else {
686
+ player.setVolume(0);
687
+ player.stop();
688
+ dispatchComplete();
689
+ initializePlayer(player);
690
+ isPrepared = false;
691
+ logger.verbose("Faded out to stop at time " + getCurrentPosition());
692
+ }
693
+ }
694
+ });
695
+ cancelFade();
696
+ logger.verbose("Fade out complete at time " + getCurrentPosition());
697
+ return;
698
+ }
699
+ final float previousCurrentVolume = currentVolume;
700
+ currentVolume -= fadeStep;
701
+ final float thisTargetVolume = Math.max(currentVolume, 0);
702
+ logger.debug(
703
+ "Fade out step: from " + previousCurrentVolume + " to " + currentVolume + " to target " + thisTargetVolume
704
+ );
705
+ owner
706
+ .getActivity()
707
+ .runOnUiThread(() -> {
708
+ if (player != null) {
709
+ player.setVolume(thisTargetVolume);
710
+ }
711
+ });
712
+ }
713
+ },
714
+ 0,
715
+ FADE_DELAY_MS,
716
+ TimeUnit.MILLISECONDS
717
+ );
718
+ }
719
+
720
+ private void fadeTo(final ExoPlayer player, float fadeDurationMs, float targetVolume) {
721
+ cancelFade();
722
+ fadeState = FadeState.FADE_TO;
723
+
724
+ final int steps = Math.max(1, (int) (fadeDurationMs / FADE_DELAY_MS));
725
+ final float minVolume = zeroVolume;
726
+ final float maxVol = maxVolume;
727
+ final float initialVolume = Math.max(player.getVolume(), minVolume);
728
+ final float finalTargetVolume = Math.max(targetVolume, minVolume);
729
+
730
+ // Clamp values to avoid overflow/underflow and invalid pow inputs
731
+ final float safeInitialVolume = Math.max(initialVolume, minVolume);
732
+ final float safeFinalTargetVolume = Math.max(finalTargetVolume, minVolume);
733
+
734
+ double ratio;
735
+ if (steps <= 0 || safeInitialVolume <= 0f || safeFinalTargetVolume <= 0f) {
736
+ ratio = 1.0;
737
+ } else if (safeInitialVolume == safeFinalTargetVolume) {
738
+ ratio = 1.0;
739
+ } else {
740
+ ratio = Math.pow(safeFinalTargetVolume / safeInitialVolume, 1.0 / steps);
741
+ if (Double.isNaN(ratio) || Double.isInfinite(ratio) || ratio <= 0.0) {
742
+ ratio = 1.0;
743
+ }
744
+ }
745
+
746
+ Log.d(
747
+ TAG,
748
+ "Beginning exponential fade from volume " +
749
+ initialVolume +
750
+ " to " +
751
+ finalTargetVolume +
752
+ " over " +
753
+ (fadeDurationMs / 1000.0) +
754
+ "s in " +
755
+ steps +
756
+ " steps (step duration: " +
757
+ (FADE_DELAY_MS / 1000.0) +
758
+ "s, ratio: " +
759
+ ratio +
760
+ ")"
761
+ );
762
+
763
+ double finalRatio = ratio;
764
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
765
+ new Runnable() {
766
+ int currentStep = 0;
767
+ float currentVolume = initialVolume;
768
+
769
+ @Override
770
+ public void run() {
771
+ if (fadeState != FadeState.FADE_TO || player == null || !player.isPlaying() || currentStep >= steps) {
772
+ fadeState = FadeState.NONE;
773
+ cancelFade();
774
+ logger.debug("Fade to complete at time " + getCurrentPosition());
775
+ return;
776
+ }
777
+ try {
778
+ if (finalRatio == 1.0) {
779
+ currentVolume = safeFinalTargetVolume;
780
+ } else {
781
+ currentVolume *= (float) finalRatio;
782
+ }
783
+ // Clamp volume between minVolume and maxVolume
784
+ currentVolume = Math.min(Math.max(currentVolume, minVolume), maxVol);
785
+ logger.verbose("Fade to step " + currentStep + ": volume set to " + currentVolume);
786
+ owner
787
+ .getActivity()
788
+ .runOnUiThread(() -> {
789
+ if (player != null && player.isPlaying()) {
790
+ player.setVolume(currentVolume);
791
+ }
792
+ });
793
+ currentStep++;
794
+ } catch (Exception e) {
795
+ logger.error("Error during fade to", e);
796
+ cancelFade();
797
+ }
798
+ }
799
+ },
800
+ 0,
801
+ FADE_DELAY_MS,
802
+ TimeUnit.MILLISECONDS
803
+ );
804
+ }
805
+
806
+ protected void cancelFade() {
807
+ if (fadeTask != null && !fadeTask.isCancelled()) {
808
+ fadeTask.cancel(true);
809
+ }
810
+ fadeState = FadeState.NONE;
811
+ fadeTask = null;
812
+ }
813
+
814
+ @Override
815
+ protected void startCurrentTimeUpdates() {
816
+ logger.debug("Starting timer updates");
817
+ if (currentTimeHandler == null) {
818
+ currentTimeHandler = new Handler(Looper.getMainLooper());
819
+ }
820
+ // Reset completion status for this assetId
821
+ dispatchedCompleteMap.put(assetId, false);
822
+
823
+ // Wait for player to be truly ready
824
+ currentTimeHandler.postDelayed(
825
+ new Runnable() {
826
+ @Override
827
+ public void run() {
828
+ if (!players.isEmpty() && playIndex >= 0 && playIndex < players.size()) {
829
+ ExoPlayer player = players.get(playIndex);
830
+ if (player != null && player.getPlaybackState() == Player.STATE_READY) {
831
+ startTimeUpdateLoop();
832
+ } else {
833
+ // Check again in 100ms
834
+ currentTimeHandler.postDelayed(this, 100);
835
+ }
836
+ }
837
+ }
838
+ },
839
+ 100
840
+ );
841
+ }
842
+
843
+ private void startTimeUpdateLoop() {
844
+ currentTimeRunnable = new Runnable() {
845
+ @Override
846
+ public void run() {
847
+ try {
848
+ boolean isPaused = false;
849
+ if (!players.isEmpty() && playIndex >= 0 && playIndex < players.size()) {
850
+ ExoPlayer player = players.get(playIndex);
851
+ if (player != null && player.getPlaybackState() == Player.STATE_READY) {
852
+ if (player.isPlaying()) {
853
+ double currentTime = player.getCurrentPosition() / 1000.0; // Get time directly
854
+ logger.debug("Play timer update: currentTime = " + currentTime);
855
+ if (owner != null) owner.notifyCurrentTime(assetId, currentTime);
856
+ currentTimeHandler.postDelayed(this, 100);
857
+ return;
858
+ } else if (!player.getPlayWhenReady()) {
859
+ isPaused = true;
860
+ }
861
+ }
862
+ }
863
+ logger.debug("Stopping play timer - not playing or not ready");
864
+ stopCurrentTimeUpdates();
865
+ if (isPaused) {
866
+ logger.verbose("Playback is paused, not dispatching complete");
867
+ } else {
868
+ logger.verbose("Playback is stopped, dispatching complete");
869
+ dispatchComplete();
870
+ }
871
+ } catch (Exception e) {
872
+ logger.error("Error getting current time", e);
873
+ stopCurrentTimeUpdates();
874
+ }
875
+ }
876
+ };
877
+ try {
878
+ if (currentTimeHandler == null) {
879
+ currentTimeHandler = new Handler(Looper.getMainLooper());
880
+ }
881
+ currentTimeHandler.post(currentTimeRunnable);
882
+ } catch (Exception e) {
883
+ logger.error("Error starting current time updates", e);
884
+ }
885
+ }
886
+ }