@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,89 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+
7
+ // Read HLS configuration from gradle.properties (set by hook script)
8
+ // Default to 'true' for backward compatibility
9
+ includeHls = project.findProperty('nativeAudio.hls.include') ?: 'true'
10
+ }
11
+
12
+ buildscript {
13
+ repositories {
14
+ google()
15
+ mavenCentral()
16
+ }
17
+ dependencies {
18
+ classpath 'com.android.tools.build:gradle:8.13.0'
19
+ }
20
+ }
21
+
22
+ apply plugin: 'com.android.library'
23
+
24
+ android {
25
+ namespace = "ee.forgr.audio.nativeaudio"
26
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
27
+ defaultConfig {
28
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
29
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
30
+ versionCode 1
31
+ versionName "1.0"
32
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
33
+ }
34
+ buildTypes {
35
+ release {
36
+ minifyEnabled false
37
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
38
+ }
39
+ }
40
+ lintOptions {
41
+ abortOnError = false
42
+ }
43
+ compileOptions {
44
+ sourceCompatibility JavaVersion.VERSION_21
45
+ targetCompatibility JavaVersion.VERSION_21
46
+ }
47
+
48
+ // Exclude StreamAudioAsset when HLS is disabled
49
+ // StreamAudioAsset depends on HlsMediaSource which is only available with media3-exoplayer-hls
50
+ sourceSets {
51
+ main {
52
+ if (includeHls != 'true') {
53
+ java.exclude '**/StreamAudioAsset.java'
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ repositories {
60
+ google()
61
+ mavenCentral()
62
+ }
63
+
64
+
65
+ dependencies {
66
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
67
+ implementation project(':capacitor-android')
68
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
69
+ testImplementation "junit:junit:$junitVersion"
70
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
71
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
72
+ implementation 'androidx.media3:media3-exoplayer:1.10.0'
73
+
74
+ // HLS (m3u8) streaming support - optional dependency
75
+ // When disabled, reduces APK size by ~4MB
76
+ // Configure via capacitor.config.ts: plugins.NativeAudio.hls = false
77
+ if (includeHls == 'true') {
78
+ implementation 'androidx.media3:media3-exoplayer-hls:1.10.0'
79
+ }
80
+
81
+ implementation 'androidx.media3:media3-session:1.10.0'
82
+ implementation 'androidx.media3:media3-transformer:1.10.0'
83
+ implementation 'androidx.media3:media3-ui:1.10.0'
84
+ implementation 'androidx.media3:media3-database:1.10.0'
85
+ implementation 'androidx.media3:media3-common:1.10.0'
86
+ // Media notification support
87
+ implementation 'androidx.media:media:1.7.1'
88
+ implementation 'androidx.core:core:1.13.1'
89
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
3
+ </manifest>
@@ -0,0 +1,611 @@
1
+ package ee.forgr.audio;
2
+
3
+ import android.content.res.AssetFileDescriptor;
4
+ import android.os.Handler;
5
+ import android.os.Looper;
6
+ import android.util.Log;
7
+ import androidx.media3.common.util.UnstableApi;
8
+ import java.util.ArrayList;
9
+ import java.util.Map;
10
+ import java.util.concurrent.ConcurrentHashMap;
11
+ import java.util.concurrent.Executors;
12
+ import java.util.concurrent.ScheduledExecutorService;
13
+ import java.util.concurrent.ScheduledFuture;
14
+ import java.util.concurrent.TimeUnit;
15
+
16
+ @UnstableApi
17
+ public class AudioAsset implements AutoCloseable {
18
+
19
+ public static final double DEFAULT_FADE_DURATION_MS = 1000.0;
20
+
21
+ private static final String TAG = "AudioAsset";
22
+ protected static final Logger logger = new Logger(TAG);
23
+
24
+ private final ArrayList<AudioDispatcher> audioList;
25
+ protected int playIndex = 0;
26
+ protected final NativeAudio owner;
27
+ protected AudioCompletionListener completionListener;
28
+ protected String assetId;
29
+ protected Handler currentTimeHandler;
30
+ protected Runnable currentTimeRunnable;
31
+ protected static final int FADE_DELAY_MS = 80;
32
+
33
+ protected ScheduledExecutorService fadeExecutor;
34
+ protected ScheduledFuture<?> fadeTask;
35
+
36
+ protected Map<String, Boolean> dispatchedCompleteMap = new ConcurrentHashMap<>();
37
+
38
+ protected enum FadeState {
39
+ NONE,
40
+ FADE_IN,
41
+ FADE_OUT,
42
+ FADE_TO
43
+ }
44
+
45
+ protected FadeState fadeState = FadeState.NONE;
46
+
47
+ protected final float zeroVolume = 0.001f;
48
+ protected final float maxVolume = 1.0f;
49
+
50
+ AudioAsset(NativeAudio owner, String assetId, AssetFileDescriptor assetFileDescriptor, int audioChannelNum, float volume)
51
+ throws Exception {
52
+ audioList = new ArrayList<>();
53
+ this.owner = owner;
54
+ this.assetId = assetId;
55
+ this.fadeExecutor = Executors.newSingleThreadScheduledExecutor();
56
+
57
+ if (audioChannelNum < 0) {
58
+ audioChannelNum = 1;
59
+ }
60
+
61
+ for (int x = 0; x < audioChannelNum; x++) {
62
+ AudioDispatcher audioDispatcher = new AudioDispatcher(assetFileDescriptor, volume);
63
+ audioList.add(audioDispatcher);
64
+ if (audioChannelNum == 1) audioDispatcher.setOwner(this);
65
+ }
66
+ }
67
+
68
+ public void dispatchComplete() {
69
+ if (dispatchedCompleteMap.getOrDefault(this.assetId, false)) {
70
+ return;
71
+ }
72
+ this.owner.dispatchComplete(this.assetId);
73
+ dispatchedCompleteMap.put(this.assetId, true);
74
+ }
75
+
76
+ public void play(double time, float volume) throws Exception {
77
+ if (audioList.isEmpty() || playIndex < 0 || playIndex >= audioList.size()) {
78
+ throw new Exception("AudioDispatcher is null or playIndex out of bounds");
79
+ }
80
+ AudioDispatcher audio = audioList.get(playIndex);
81
+ if (audio != null) {
82
+ cancelFade();
83
+ audio.play(time);
84
+ audio.setVolume(volume);
85
+ playIndex++;
86
+ playIndex = playIndex % audioList.size();
87
+ logger.debug("Starting timer from play"); // Debug log
88
+ startCurrentTimeUpdates(); // Make sure this is called
89
+ } else {
90
+ throw new Exception("AudioDispatcher is null");
91
+ }
92
+ }
93
+
94
+ public void play(double time) throws Exception {
95
+ play(time, 1.0f);
96
+ }
97
+
98
+ public double getDuration() {
99
+ if (audioList.size() != 1 || playIndex < 0 || playIndex >= audioList.size()) return 0;
100
+ AudioDispatcher audio = audioList.get(playIndex);
101
+ if (audio != null) {
102
+ return audio.getDuration();
103
+ }
104
+ return 0;
105
+ }
106
+
107
+ public void setCurrentPosition(double time) {
108
+ if (audioList.size() != 1 || playIndex < 0 || playIndex >= audioList.size()) return;
109
+ AudioDispatcher audio = audioList.get(playIndex);
110
+ if (audio != null) {
111
+ audio.setCurrentPosition(time);
112
+ }
113
+ }
114
+
115
+ public double getCurrentPosition() {
116
+ if (audioList.size() != 1 || playIndex < 0 || playIndex >= audioList.size()) return 0;
117
+ AudioDispatcher audio = audioList.get(playIndex);
118
+ if (audio != null) {
119
+ return audio.getCurrentPosition();
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ public boolean pause() throws Exception {
125
+ stopCurrentTimeUpdates(); // Stop updates when pausing
126
+ boolean wasPlaying = false;
127
+
128
+ for (int x = 0; x < audioList.size(); x++) {
129
+ AudioDispatcher audio = audioList.get(x);
130
+ if (audio == null) {
131
+ continue;
132
+ }
133
+ cancelFade();
134
+ wasPlaying |= audio.pause();
135
+ }
136
+
137
+ return wasPlaying;
138
+ }
139
+
140
+ public void resume() throws Exception {
141
+ if (!audioList.isEmpty()) {
142
+ AudioDispatcher audio = audioList.get(0);
143
+ if (audio != null) {
144
+ audio.resume();
145
+ logger.debug("Starting timer from resume"); // Debug log
146
+ startCurrentTimeUpdates(); // Make sure this is called
147
+ } else {
148
+ throw new Exception("AudioDispatcher is null");
149
+ }
150
+ }
151
+ }
152
+
153
+ public void stop() throws Exception {
154
+ stopCurrentTimeUpdates(); // Stop updates when stopping
155
+ dispatchComplete();
156
+ for (int x = 0; x < audioList.size(); x++) {
157
+ AudioDispatcher audio = audioList.get(x);
158
+
159
+ if (audio != null) {
160
+ cancelFade();
161
+ audio.stop();
162
+ } else {
163
+ throw new Exception("AudioDispatcher is null");
164
+ }
165
+ }
166
+ }
167
+
168
+ public void loop() throws Exception {
169
+ AudioDispatcher audio = audioList.get(playIndex);
170
+ if (audio != null) {
171
+ audio.loop();
172
+ playIndex++;
173
+ playIndex = playIndex % audioList.size();
174
+ startCurrentTimeUpdates(); // Add timer start
175
+ } else {
176
+ throw new Exception("AudioDispatcher is null");
177
+ }
178
+ }
179
+
180
+ public void unload() throws Exception {
181
+ this.stop();
182
+
183
+ for (int x = 0; x < audioList.size(); x++) {
184
+ AudioDispatcher audio = audioList.get(x);
185
+
186
+ if (audio != null) {
187
+ audio.unload();
188
+ } else {
189
+ throw new Exception("AudioDispatcher is null");
190
+ }
191
+ }
192
+
193
+ audioList.clear();
194
+ stopCurrentTimeUpdates();
195
+ close();
196
+ }
197
+
198
+ public void setVolume(float volume, double duration) throws Exception {
199
+ for (int x = 0; x < audioList.size(); x++) {
200
+ AudioDispatcher audio = audioList.get(x);
201
+
202
+ cancelFade();
203
+ if (audio != null) {
204
+ if (isPlaying() && duration > 0) {
205
+ fadeTo(audio, duration, volume);
206
+ } else {
207
+ audio.setVolume(volume);
208
+ }
209
+ } else {
210
+ throw new Exception("AudioDispatcher is null");
211
+ }
212
+ }
213
+ }
214
+
215
+ public void setVolume(float volume) throws Exception {
216
+ setVolume(volume, 0);
217
+ }
218
+
219
+ public float getVolume() throws Exception {
220
+ if (audioList.size() != 1 || playIndex < 0 || playIndex >= audioList.size()) return 0;
221
+ AudioDispatcher audio = audioList.get(playIndex);
222
+ if (audio != null) {
223
+ return audio.getVolume();
224
+ }
225
+ throw new Exception("AudioDispatcher is null");
226
+ }
227
+
228
+ public void setRate(float rate) throws Exception {
229
+ for (int x = 0; x < audioList.size(); x++) {
230
+ AudioDispatcher audio = audioList.get(x);
231
+ if (audio != null) {
232
+ audio.setRate(rate);
233
+ }
234
+ }
235
+ }
236
+
237
+ public boolean isPlaying() throws Exception {
238
+ for (AudioDispatcher ad : audioList) {
239
+ if (ad != null && ad.isPlaying()) return true;
240
+ }
241
+ return false;
242
+ }
243
+
244
+ public void setCompletionListener(AudioCompletionListener listener) {
245
+ this.completionListener = listener;
246
+ }
247
+
248
+ protected void notifyCompletion() {
249
+ if (completionListener != null) {
250
+ completionListener.onCompletion(this.assetId);
251
+ }
252
+ }
253
+
254
+ protected String getAssetId() {
255
+ return assetId;
256
+ }
257
+
258
+ public void setCurrentTime(double time) throws Exception {
259
+ if (owner == null || owner.getActivity() == null) return;
260
+ owner
261
+ .getActivity()
262
+ .runOnUiThread(
263
+ new Runnable() {
264
+ @Override
265
+ public void run() {
266
+ if (audioList.size() != 1 || playIndex < 0 || playIndex >= audioList.size()) {
267
+ return;
268
+ }
269
+ AudioDispatcher audio = audioList.get(playIndex);
270
+ if (audio != null) {
271
+ audio.setCurrentPosition(time);
272
+ }
273
+ }
274
+ }
275
+ );
276
+ }
277
+
278
+ protected void startCurrentTimeUpdates() {
279
+ logger.debug("Starting timer updates");
280
+ if (currentTimeHandler == null) {
281
+ currentTimeHandler = new Handler(Looper.getMainLooper());
282
+ }
283
+ dispatchedCompleteMap.put(assetId, false);
284
+ currentTimeHandler.postDelayed(
285
+ new Runnable() {
286
+ @Override
287
+ public void run() {
288
+ startTimeUpdateLoop();
289
+ }
290
+ },
291
+ 100
292
+ );
293
+ }
294
+
295
+ private void startTimeUpdateLoop() {
296
+ currentTimeRunnable = new Runnable() {
297
+ @Override
298
+ public void run() {
299
+ AudioDispatcher audio = null;
300
+ try {
301
+ if (audioList.isEmpty() || playIndex < 0 || playIndex >= audioList.size()) {
302
+ logger.verbose("Audio dispatcher does not exist at index " + playIndex);
303
+ return;
304
+ }
305
+ audio = audioList.get(playIndex);
306
+ } catch (Exception e) {
307
+ logger.verbose("Audio dispatcher does not exist at index " + playIndex);
308
+ }
309
+ if (audio == null) {
310
+ logger.debug("Audio dispatcher does not exist - aborting timer update");
311
+ return;
312
+ }
313
+ try {
314
+ if (audio != null && audio.isPlaying()) {
315
+ double currentTime = getCurrentPosition();
316
+ logger.verbose("Play timer update: currentTime = " + currentTime);
317
+ if (owner != null) owner.notifyCurrentTime(assetId, currentTime);
318
+ currentTimeHandler.postDelayed(this, 100);
319
+ } else {
320
+ logger.debug("Audio is not not playing");
321
+ stopCurrentTimeUpdates();
322
+ if (audio.isPaused()) {
323
+ logger.verbose("Audio is paused");
324
+ } else {
325
+ logger.verbose("Audio is not paused - dispatching complete");
326
+ dispatchComplete();
327
+ }
328
+ }
329
+ } catch (Exception e) {
330
+ logger.error("Error getting current time", e);
331
+ stopCurrentTimeUpdates();
332
+ }
333
+ }
334
+ };
335
+ try {
336
+ if (currentTimeHandler == null) {
337
+ currentTimeHandler = new Handler(Looper.getMainLooper());
338
+ }
339
+ currentTimeHandler.post(currentTimeRunnable);
340
+ } catch (Exception e) {
341
+ logger.error("Error starting current time updates", e);
342
+ }
343
+ }
344
+
345
+ void stopCurrentTimeUpdates() {
346
+ logger.verbose("Stopping play timer updates");
347
+ if (currentTimeHandler != null && currentTimeRunnable != null) {
348
+ currentTimeHandler.removeCallbacks(currentTimeRunnable);
349
+ currentTimeHandler = null;
350
+ currentTimeRunnable = null;
351
+ }
352
+ }
353
+
354
+ public void playWithFadeIn(double time, float volume, double fadeInDurationMs) throws Exception {
355
+ AudioDispatcher audio = audioList.get(playIndex);
356
+ if (audio != null) {
357
+ audio.setVolume(0);
358
+ audio.play(time);
359
+ fadeIn(audio, fadeInDurationMs, volume);
360
+ startCurrentTimeUpdates();
361
+ }
362
+ }
363
+
364
+ public void playWithFade(double time) throws Exception {
365
+ playWithFadeIn(time, 1.0f, DEFAULT_FADE_DURATION_MS);
366
+ }
367
+
368
+ private void fadeIn(final AudioDispatcher audio, double fadeInDurationMs, float targetVolume) {
369
+ cancelFade();
370
+ fadeState = FadeState.FADE_IN;
371
+
372
+ final int steps = Math.max(1, (int) (fadeInDurationMs / FADE_DELAY_MS));
373
+ final float fadeStep = targetVolume / steps;
374
+
375
+ Log.d(
376
+ TAG,
377
+ "Beginning fade in at time " +
378
+ getCurrentPosition() +
379
+ " over " +
380
+ (fadeInDurationMs / 1000.0) +
381
+ "s to target volume " +
382
+ targetVolume +
383
+ " in " +
384
+ steps +
385
+ " steps (step duration: " +
386
+ (FADE_DELAY_MS / 1000.0) +
387
+ "s"
388
+ );
389
+
390
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
391
+ new Runnable() {
392
+ float currentVolume = 0;
393
+
394
+ @Override
395
+ public void run() {
396
+ if (fadeState != FadeState.FADE_IN || currentVolume >= targetVolume) {
397
+ fadeState = FadeState.NONE;
398
+ cancelFade();
399
+ logger.debug("Fade in complete at time " + getCurrentPosition());
400
+ return;
401
+ }
402
+ final float previousCurrentVolume = currentVolume;
403
+ currentVolume += fadeStep;
404
+ try {
405
+ final float resolvedTargetVolume = Math.min(Math.max(currentVolume, 0), targetVolume);
406
+ Log.v(
407
+ TAG,
408
+ "Fade in step: from " + previousCurrentVolume + " to " + currentVolume + " to target " + resolvedTargetVolume
409
+ );
410
+ if (audio != null) audio.setVolume(resolvedTargetVolume);
411
+ } catch (Exception e) {
412
+ logger.error("Error during fade in", e);
413
+ cancelFade();
414
+ }
415
+ }
416
+ },
417
+ 0,
418
+ FADE_DELAY_MS,
419
+ TimeUnit.MILLISECONDS
420
+ );
421
+ }
422
+
423
+ public void stopWithFade(double fadeOutDurationMs, boolean toPause) throws Exception {
424
+ AudioDispatcher audio = audioList.get(playIndex);
425
+ if (audio != null && audio.isPlaying()) {
426
+ cancelFade();
427
+ fadeOut(audio, fadeOutDurationMs, toPause);
428
+ } else if (!toPause) {
429
+ stop();
430
+ }
431
+ }
432
+
433
+ public void stopWithFade() throws Exception {
434
+ stopWithFade(DEFAULT_FADE_DURATION_MS, false);
435
+ }
436
+
437
+ private void fadeOut(final AudioDispatcher audio, double fadeOutDurationMs, boolean toPause) {
438
+ cancelFade();
439
+ fadeState = FadeState.FADE_OUT;
440
+
441
+ if (audio == null) return;
442
+
443
+ final int steps = Math.max(1, (int) (fadeOutDurationMs / FADE_DELAY_MS));
444
+ final float initialVolume = audio.getVolume();
445
+ final float fadeStep = initialVolume / steps;
446
+
447
+ Log.d(
448
+ TAG,
449
+ "Beginning fade out from volume " +
450
+ initialVolume +
451
+ " at time " +
452
+ getCurrentPosition() +
453
+ " over " +
454
+ (fadeOutDurationMs / 1000.0) +
455
+ "s in " +
456
+ steps +
457
+ " steps (step duration: " +
458
+ (FADE_DELAY_MS / 1000.0) +
459
+ "s)"
460
+ );
461
+
462
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
463
+ new Runnable() {
464
+ float currentVolume = initialVolume;
465
+
466
+ @Override
467
+ public void run() {
468
+ try {
469
+ if (fadeState != FadeState.FADE_OUT || currentVolume <= 0) {
470
+ fadeState = FadeState.NONE;
471
+ if (toPause) {
472
+ logger.verbose("Faded out to pause audio at time " + getCurrentPosition());
473
+ audio.pause();
474
+ } else {
475
+ logger.verbose("Faded out to stop at time " + getCurrentPosition());
476
+ stop();
477
+ }
478
+ cancelFade();
479
+ logger.debug("Fade out complete at time " + getCurrentPosition());
480
+ return;
481
+ }
482
+ final float previousCurrentVolume = currentVolume;
483
+ currentVolume -= fadeStep;
484
+
485
+ final float thisTargetVolume = Math.max(currentVolume, 0);
486
+ Log.v(
487
+ TAG,
488
+ "Fade out step: from " + previousCurrentVolume + " to " + currentVolume + " to target " + thisTargetVolume
489
+ );
490
+ if (audio != null) audio.setVolume(thisTargetVolume);
491
+ } catch (Exception e) {
492
+ logger.error("Error during fade out", e);
493
+ cancelFade();
494
+ }
495
+ }
496
+ },
497
+ 0,
498
+ FADE_DELAY_MS,
499
+ TimeUnit.MILLISECONDS
500
+ );
501
+ }
502
+
503
+ protected void fadeTo(final AudioDispatcher audio, double fadeDurationMs, float targetVolume) {
504
+ cancelFade();
505
+ fadeState = FadeState.FADE_TO;
506
+
507
+ if (audio == null) return;
508
+
509
+ final int steps = Math.max(1, (int) (fadeDurationMs / FADE_DELAY_MS));
510
+ final float minVolume = zeroVolume;
511
+ final float initialVolume = Math.max(audio.getVolume(), minVolume);
512
+ final float finalTargetVolume = Math.max(targetVolume, minVolume);
513
+
514
+ // Clamp values to avoid overflow/underflow and invalid pow inputs
515
+ final float safeInitialVolume = Math.max(initialVolume, minVolume);
516
+ final float safeFinalTargetVolume = Math.max(finalTargetVolume, minVolume);
517
+
518
+ double ratio;
519
+ if (steps <= 0 || safeInitialVolume <= 0f || safeFinalTargetVolume <= 0f) {
520
+ ratio = 1.0; // No fade or invalid, just set directly
521
+ } else if (safeInitialVolume == safeFinalTargetVolume) {
522
+ ratio = 1.0;
523
+ } else {
524
+ ratio = Math.pow(safeFinalTargetVolume / safeInitialVolume, 1.0 / steps);
525
+ // Clamp ratio to reasonable bounds to avoid overflow
526
+ if (Double.isNaN(ratio) || Double.isInfinite(ratio) || ratio <= 0.0) {
527
+ ratio = 1.0;
528
+ }
529
+ }
530
+
531
+ Log.d(
532
+ TAG,
533
+ "Beginning exponential fade from volume " +
534
+ initialVolume +
535
+ " to " +
536
+ finalTargetVolume +
537
+ " over " +
538
+ (fadeDurationMs / 1000.0) +
539
+ "s in " +
540
+ steps +
541
+ " steps (step duration: " +
542
+ (FADE_DELAY_MS / 1000.0) +
543
+ "s, ratio: " +
544
+ ratio +
545
+ ")"
546
+ );
547
+
548
+ double finalRatio = ratio;
549
+ fadeTask = fadeExecutor.scheduleWithFixedDelay(
550
+ new Runnable() {
551
+ int currentStep = 0;
552
+ float currentVolume = initialVolume;
553
+
554
+ @Override
555
+ public void run() {
556
+ if ((audio != null && fadeState != FadeState.FADE_TO) || !audio.isPlaying() || currentStep >= steps) {
557
+ fadeState = FadeState.NONE;
558
+ cancelFade();
559
+ logger.debug("Fade to complete at time " + getCurrentPosition());
560
+ return;
561
+ }
562
+
563
+ try {
564
+ if (finalRatio == 1.0) {
565
+ currentVolume = safeFinalTargetVolume;
566
+ } else {
567
+ currentVolume *= (float) finalRatio;
568
+ }
569
+ currentVolume = Math.min(Math.max(currentVolume, minVolume), maxVolume); // Clamp between minVolume and maxVolume
570
+ if (audio != null) audio.setVolume(currentVolume);
571
+ logger.verbose("Fade to step " + currentStep + ": volume set to " + currentVolume);
572
+ currentStep++;
573
+ } catch (Exception e) {
574
+ logger.error("Error during fade to", e);
575
+ cancelFade();
576
+ }
577
+ }
578
+ },
579
+ 0,
580
+ FADE_DELAY_MS,
581
+ TimeUnit.MILLISECONDS
582
+ );
583
+ }
584
+
585
+ /**
586
+ * Cancels the fade task if it is running.
587
+ */
588
+ protected void cancelFade() {
589
+ if (fadeTask != null && !fadeTask.isCancelled()) {
590
+ fadeTask.cancel(true);
591
+ }
592
+ fadeState = FadeState.NONE;
593
+ fadeTask = null;
594
+ }
595
+
596
+ @Override
597
+ public void close() {
598
+ if (fadeExecutor != null && !fadeExecutor.isShutdown()) {
599
+ fadeExecutor.shutdown();
600
+ }
601
+ }
602
+
603
+ @Override
604
+ protected void finalize() throws Throwable {
605
+ try {
606
+ close();
607
+ } finally {
608
+ super.finalize();
609
+ }
610
+ }
611
+ }