@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,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
|
+
}
|