Updated packages, rewrote player = gapless playback, faster loading
This commit is contained in:
parent
6f250df004
commit
d4299f736f
92 changed files with 10270 additions and 1450 deletions
8
just_audio/android/.gitignore
vendored
Normal file
8
just_audio/android/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
48
just_audio/android/build.gradle
Normal file
48
just_audio/android/build.gradle
Normal file
|
@ -0,0 +1,48 @@
|
|||
group 'com.ryanheise.just_audio'
|
||||
version '1.0'
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.6.3'
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 16
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'InvalidPackage'
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.11.4'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-dash:2.11.4'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-hls:2.11.4'
|
||||
implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.11.4'
|
||||
compile files('libs/extension-flac.aar')
|
||||
}
|
4
just_audio/android/gradle.properties
Normal file
4
just_audio/android/gradle.properties
Normal file
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
6
just_audio/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
just_audio/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
#Mon Aug 10 13:15:44 CEST 2020
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
|
BIN
just_audio/android/libs/extension-flac.aar
Normal file
BIN
just_audio/android/libs/extension-flac.aar
Normal file
Binary file not shown.
1
just_audio/android/settings.gradle
Normal file
1
just_audio/android/settings.gradle
Normal file
|
@ -0,0 +1 @@
|
|||
rootProject.name = 'just_audio'
|
3
just_audio/android/src/main/AndroidManifest.xml
Normal file
3
just_audio/android/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.ryanheise.just_audio">
|
||||
</manifest>
|
|
@ -0,0 +1,723 @@
|
|||
package com.ryanheise.just_audio;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.metadata.Metadata;
|
||||
import com.google.android.exoplayer2.metadata.MetadataOutput;
|
||||
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
|
||||
import com.google.android.exoplayer2.metadata.icy.IcyInfo;
|
||||
import com.google.android.exoplayer2.source.ClippingMediaSource;
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
||||
import com.google.android.exoplayer2.source.LoopingMediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.source.ShuffleOrder;
|
||||
import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder;
|
||||
import com.google.android.exoplayer2.source.TrackGroup;
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray;
|
||||
import com.google.android.exoplayer2.source.dash.DashMediaSource;
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource;
|
||||
import com.google.android.exoplayer2.util.Util;
|
||||
import io.flutter.Log;
|
||||
import io.flutter.plugin.common.BinaryMessenger;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import io.flutter.plugin.common.EventChannel.EventSink;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
|
||||
import io.flutter.plugin.common.MethodChannel.Result;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
import com.ryanheise.just_audio.DeezerDataSource;
|
||||
|
||||
public class AudioPlayer implements MethodCallHandler, Player.EventListener, MetadataOutput {
|
||||
|
||||
static final String TAG = "AudioPlayer";
|
||||
|
||||
private static Random random = new Random();
|
||||
|
||||
private final Context context;
|
||||
private final MethodChannel methodChannel;
|
||||
private final EventChannel eventChannel;
|
||||
private EventSink eventSink;
|
||||
|
||||
private ProcessingState processingState;
|
||||
private long updateTime;
|
||||
private long updatePosition;
|
||||
private long bufferedPosition;
|
||||
private long duration;
|
||||
private Long start;
|
||||
private Long end;
|
||||
private Long seekPos;
|
||||
private Result prepareResult;
|
||||
private Result playResult;
|
||||
private Result seekResult;
|
||||
private boolean seekProcessed;
|
||||
private boolean playing;
|
||||
private Map<String, MediaSource> mediaSources = new HashMap<String, MediaSource>();
|
||||
private IcyInfo icyInfo;
|
||||
private IcyHeaders icyHeaders;
|
||||
private int errorCount;
|
||||
|
||||
private SimpleExoPlayer player;
|
||||
private MediaSource mediaSource;
|
||||
private Integer currentIndex;
|
||||
private Map<LoopingMediaSource, MediaSource> loopingChildren = new HashMap<>();
|
||||
private Map<LoopingMediaSource, Integer> loopingCounts = new HashMap<>();
|
||||
private final Handler handler = new Handler();
|
||||
private final Runnable bufferWatcher = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long newBufferedPosition = player.getBufferedPosition();
|
||||
if (newBufferedPosition != bufferedPosition) {
|
||||
bufferedPosition = newBufferedPosition;
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
switch (processingState) {
|
||||
case buffering:
|
||||
handler.postDelayed(this, 200);
|
||||
break;
|
||||
case ready:
|
||||
if (playing) {
|
||||
handler.postDelayed(this, 500);
|
||||
} else {
|
||||
handler.postDelayed(this, 1000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final Runnable onDispose;
|
||||
|
||||
public AudioPlayer(final Context applicationContext, final BinaryMessenger messenger,
|
||||
final String id, final Runnable onDispose) {
|
||||
this.context = applicationContext;
|
||||
this.onDispose = onDispose;
|
||||
methodChannel = new MethodChannel(messenger, "com.ryanheise.just_audio.methods." + id);
|
||||
methodChannel.setMethodCallHandler(this);
|
||||
eventChannel = new EventChannel(messenger, "com.ryanheise.just_audio.events." + id);
|
||||
eventChannel.setStreamHandler(new EventChannel.StreamHandler() {
|
||||
@Override
|
||||
public void onListen(final Object arguments, final EventSink eventSink) {
|
||||
AudioPlayer.this.eventSink = eventSink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(final Object arguments) {
|
||||
eventSink = null;
|
||||
}
|
||||
});
|
||||
processingState = ProcessingState.none;
|
||||
}
|
||||
|
||||
private void startWatchingBuffer() {
|
||||
handler.removeCallbacks(bufferWatcher);
|
||||
handler.post(bufferWatcher);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMetadata(Metadata metadata) {
|
||||
for (int i = 0; i < metadata.length(); i++) {
|
||||
final Metadata.Entry entry = metadata.get(i);
|
||||
if (entry instanceof IcyInfo) {
|
||||
icyInfo = (IcyInfo) entry;
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
|
||||
for (int i = 0; i < trackGroups.length; i++) {
|
||||
TrackGroup trackGroup = trackGroups.get(i);
|
||||
|
||||
for (int j = 0; j < trackGroup.length; j++) {
|
||||
Metadata metadata = trackGroup.getFormat(j).metadata;
|
||||
|
||||
if (metadata != null) {
|
||||
for (int k = 0; k < metadata.length(); k++) {
|
||||
final Metadata.Entry entry = metadata.get(k);
|
||||
if (entry instanceof IcyHeaders) {
|
||||
icyHeaders = (IcyHeaders) entry;
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPositionDiscontinuity(int reason) {
|
||||
switch (reason) {
|
||||
case Player.DISCONTINUITY_REASON_PERIOD_TRANSITION:
|
||||
case Player.DISCONTINUITY_REASON_SEEK:
|
||||
onItemMayHaveChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_DYNAMIC) {
|
||||
onItemMayHaveChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void onItemMayHaveChanged() {
|
||||
Integer newIndex = player.getCurrentWindowIndex();
|
||||
if (newIndex != currentIndex) {
|
||||
currentIndex = newIndex;
|
||||
}
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
|
||||
switch (playbackState) {
|
||||
case Player.STATE_READY:
|
||||
if (prepareResult != null) {
|
||||
duration = getDuration();
|
||||
transition(ProcessingState.ready);
|
||||
prepareResult.success(duration);
|
||||
prepareResult = null;
|
||||
} else {
|
||||
transition(ProcessingState.ready);
|
||||
}
|
||||
if (seekProcessed) {
|
||||
completeSeek();
|
||||
}
|
||||
break;
|
||||
case Player.STATE_BUFFERING:
|
||||
if (processingState != ProcessingState.buffering) {
|
||||
transition(ProcessingState.buffering);
|
||||
startWatchingBuffer();
|
||||
}
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
if (processingState != ProcessingState.completed) {
|
||||
transition(ProcessingState.completed);
|
||||
}
|
||||
if (playResult != null) {
|
||||
playResult.success(null);
|
||||
playResult = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(ExoPlaybackException error) {
|
||||
switch (error.type) {
|
||||
case ExoPlaybackException.TYPE_SOURCE:
|
||||
Log.e(TAG, "TYPE_SOURCE: " + error.getSourceException().getMessage());
|
||||
break;
|
||||
|
||||
case ExoPlaybackException.TYPE_RENDERER:
|
||||
Log.e(TAG, "TYPE_RENDERER: " + error.getRendererException().getMessage());
|
||||
break;
|
||||
|
||||
case ExoPlaybackException.TYPE_UNEXPECTED:
|
||||
Log.e(TAG, "TYPE_UNEXPECTED: " + error.getUnexpectedException().getMessage());
|
||||
break;
|
||||
|
||||
default:
|
||||
Log.e(TAG, "default: " + error.getUnexpectedException().getMessage());
|
||||
}
|
||||
sendError(String.valueOf(error.type), error.getMessage());
|
||||
errorCount++;
|
||||
if (player.hasNext() && currentIndex != null && errorCount <= 5) {
|
||||
int nextIndex = currentIndex + 1;
|
||||
player.prepare(mediaSource);
|
||||
player.seekTo(nextIndex, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSeekProcessed() {
|
||||
if (seekResult != null) {
|
||||
seekProcessed = true;
|
||||
if (player.getPlaybackState() == Player.STATE_READY) {
|
||||
completeSeek();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void completeSeek() {
|
||||
seekProcessed = false;
|
||||
seekPos = null;
|
||||
seekResult.success(null);
|
||||
seekResult = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(final MethodCall call, final Result result) {
|
||||
ensurePlayerInitialized();
|
||||
|
||||
final List<?> args = (List<?>) call.arguments;
|
||||
try {
|
||||
switch (call.method) {
|
||||
case "load":
|
||||
load(getAudioSource(args.get(0)), result);
|
||||
break;
|
||||
case "play":
|
||||
play(result);
|
||||
break;
|
||||
case "pause":
|
||||
pause();
|
||||
result.success(null);
|
||||
break;
|
||||
case "setVolume":
|
||||
setVolume((float) ((double) ((Double) args.get(0))));
|
||||
result.success(null);
|
||||
break;
|
||||
case "setSpeed":
|
||||
setSpeed((float) ((double) ((Double) args.get(0))));
|
||||
result.success(null);
|
||||
break;
|
||||
case "setLoopMode":
|
||||
setLoopMode((Integer) args.get(0));
|
||||
result.success(null);
|
||||
break;
|
||||
case "setShuffleModeEnabled":
|
||||
setShuffleModeEnabled((Boolean) args.get(0));
|
||||
result.success(null);
|
||||
break;
|
||||
case "setAutomaticallyWaitsToMinimizeStalling":
|
||||
result.success(null);
|
||||
break;
|
||||
case "seek":
|
||||
Long position = getLong(args.get(0));
|
||||
Integer index = (Integer)args.get(1);
|
||||
seek(position == null ? C.TIME_UNSET : position, result, index);
|
||||
break;
|
||||
case "dispose":
|
||||
dispose();
|
||||
result.success(null);
|
||||
break;
|
||||
case "concatenating.add":
|
||||
concatenating(args.get(0))
|
||||
.addMediaSource(getAudioSource(args.get(1)), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.insert":
|
||||
concatenating(args.get(0))
|
||||
.addMediaSource((Integer)args.get(1), getAudioSource(args.get(2)), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.addAll":
|
||||
concatenating(args.get(0))
|
||||
.addMediaSources(getAudioSources(args.get(1)), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.insertAll":
|
||||
concatenating(args.get(0))
|
||||
.addMediaSources((Integer)args.get(1), getAudioSources(args.get(2)), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.removeAt":
|
||||
concatenating(args.get(0))
|
||||
.removeMediaSource((Integer)args.get(1), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.removeRange":
|
||||
concatenating(args.get(0))
|
||||
.removeMediaSourceRange((Integer)args.get(1), (Integer)args.get(2), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.move":
|
||||
concatenating(args.get(0))
|
||||
.moveMediaSource((Integer)args.get(1), (Integer)args.get(2), handler, () -> result.success(null));
|
||||
break;
|
||||
case "concatenating.clear":
|
||||
concatenating(args.get(0)).clear(handler, () -> result.success(null));
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
e.printStackTrace();
|
||||
result.error("Illegal state: " + e.getMessage(), null, null);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
result.error("Error: " + e, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the shuffle order for mediaSource, with currentIndex at
|
||||
// the first position. Traverse the tree incrementing index at each
|
||||
// node.
|
||||
private int setShuffleOrder(MediaSource mediaSource, int index) {
|
||||
if (mediaSource instanceof ConcatenatingMediaSource) {
|
||||
final ConcatenatingMediaSource source = (ConcatenatingMediaSource)mediaSource;
|
||||
// Find which child is current
|
||||
Integer currentChildIndex = null;
|
||||
for (int i = 0; i < source.getSize(); i++) {
|
||||
final int indexBefore = index;
|
||||
final MediaSource child = source.getMediaSource(i);
|
||||
index = setShuffleOrder(child, index);
|
||||
// If currentIndex falls within this child, make this child come first.
|
||||
if (currentIndex >= indexBefore && currentIndex < index) {
|
||||
currentChildIndex = i;
|
||||
}
|
||||
}
|
||||
// Shuffle so that the current child is first in the shuffle order
|
||||
source.setShuffleOrder(createShuffleOrder(source.getSize(), currentChildIndex));
|
||||
} else if (mediaSource instanceof LoopingMediaSource) {
|
||||
final LoopingMediaSource source = (LoopingMediaSource)mediaSource;
|
||||
// The ExoPlayer API doesn't provide accessors for these so we have
|
||||
// to index them ourselves.
|
||||
MediaSource child = loopingChildren.get(source);
|
||||
int count = loopingCounts.get(source);
|
||||
for (int i = 0; i < count; i++) {
|
||||
index = setShuffleOrder(child, index);
|
||||
}
|
||||
} else {
|
||||
// An actual media item takes up one spot in the playlist.
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int[] shuffle(int length, Integer firstIndex) {
|
||||
final int[] shuffleOrder = new int[length];
|
||||
for (int i = 0; i < length; i++) {
|
||||
final int j = random.nextInt(i + 1);
|
||||
shuffleOrder[i] = shuffleOrder[j];
|
||||
shuffleOrder[j] = i;
|
||||
}
|
||||
if (firstIndex != null) {
|
||||
for (int i = 1; i < length; i++) {
|
||||
if (shuffleOrder[i] == firstIndex) {
|
||||
final int v = shuffleOrder[0];
|
||||
shuffleOrder[0] = shuffleOrder[i];
|
||||
shuffleOrder[i] = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return shuffleOrder;
|
||||
}
|
||||
|
||||
// Create a shuffle order optionally fixing the first index.
|
||||
private ShuffleOrder createShuffleOrder(int length, Integer firstIndex) {
|
||||
int[] shuffleIndices = shuffle(length, firstIndex);
|
||||
return new DefaultShuffleOrder(shuffleIndices, random.nextLong());
|
||||
}
|
||||
|
||||
private ConcatenatingMediaSource concatenating(final Object index) {
|
||||
return (ConcatenatingMediaSource)mediaSources.get((String)index);
|
||||
}
|
||||
|
||||
private MediaSource getAudioSource(final Object json) {
|
||||
Map<?, ?> map = (Map<?, ?>)json;
|
||||
String id = (String)map.get("id");
|
||||
MediaSource mediaSource = mediaSources.get(id);
|
||||
if (mediaSource == null) {
|
||||
mediaSource = decodeAudioSource(map);
|
||||
mediaSources.put(id, mediaSource);
|
||||
}
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
private MediaSource decodeAudioSource(final Object json) {
|
||||
Map<?, ?> map = (Map<?, ?>)json;
|
||||
String id = (String)map.get("id");
|
||||
switch ((String)map.get("type")) {
|
||||
case "progressive":
|
||||
Uri uri = Uri.parse((String)map.get("uri"));
|
||||
//Deezer
|
||||
if (uri.getHost().contains("dzcdn.net")) {
|
||||
//Track id is stored in URL fragment (after #)
|
||||
String fragment = uri.getFragment();
|
||||
uri = Uri.parse(((String)map.get("uri")).replace("#" + fragment, ""));
|
||||
return new ProgressiveMediaSource.Factory(
|
||||
() -> {
|
||||
HttpDataSource deezerDataSource = new DeezerDataSource(fragment);
|
||||
return deezerDataSource;
|
||||
}
|
||||
).setTag(id).createMediaSource(uri);
|
||||
}
|
||||
|
||||
return new ProgressiveMediaSource.Factory(buildDataSourceFactory())
|
||||
.setTag(id)
|
||||
.createMediaSource(uri);
|
||||
case "dash":
|
||||
return new DashMediaSource.Factory(buildDataSourceFactory())
|
||||
.setTag(id)
|
||||
.createMediaSource(Uri.parse((String)map.get("uri")));
|
||||
case "hls":
|
||||
return new HlsMediaSource.Factory(buildDataSourceFactory())
|
||||
.setTag(id)
|
||||
.createMediaSource(Uri.parse((String)map.get("uri")));
|
||||
case "concatenating":
|
||||
List<Object> audioSources = (List<Object>)map.get("audioSources");
|
||||
return new ConcatenatingMediaSource(
|
||||
false, // isAtomic
|
||||
(Boolean)map.get("useLazyPreparation"),
|
||||
new DefaultShuffleOrder(audioSources.size()),
|
||||
audioSources
|
||||
.stream()
|
||||
.map(s -> getAudioSource(s))
|
||||
.toArray(MediaSource[]::new));
|
||||
case "clipping":
|
||||
Long start = getLong(map.get("start"));
|
||||
Long end = getLong(map.get("end"));
|
||||
return new ClippingMediaSource(getAudioSource(map.get("audioSource")),
|
||||
(start != null ? start : 0) * 1000L,
|
||||
(end != null ? end : C.TIME_END_OF_SOURCE) * 1000L);
|
||||
case "looping":
|
||||
Integer count = (Integer)map.get("count");
|
||||
MediaSource looperChild = getAudioSource(map.get("audioSource"));
|
||||
LoopingMediaSource looper = new LoopingMediaSource(looperChild, count);
|
||||
// TODO: store both in a single map
|
||||
loopingChildren.put(looper, looperChild);
|
||||
loopingCounts.put(looper, count);
|
||||
return looper;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown AudioSource type: " + map.get("type"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<MediaSource> getAudioSources(final Object json) {
|
||||
return ((List<Object>)json)
|
||||
.stream()
|
||||
.map(s -> getAudioSource(s))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private DataSource.Factory buildDataSourceFactory() {
|
||||
String userAgent = Util.getUserAgent(context, "just_audio");
|
||||
DataSource.Factory httpDataSourceFactory = new DefaultHttpDataSourceFactory(
|
||||
userAgent,
|
||||
DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS,
|
||||
DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS,
|
||||
true
|
||||
);
|
||||
return new DefaultDataSourceFactory(context, httpDataSourceFactory);
|
||||
}
|
||||
|
||||
private void load(final MediaSource mediaSource, final Result result) {
|
||||
switch (processingState) {
|
||||
case none:
|
||||
break;
|
||||
case loading:
|
||||
abortExistingConnection();
|
||||
player.stop();
|
||||
break;
|
||||
default:
|
||||
player.stop();
|
||||
break;
|
||||
}
|
||||
errorCount = 0;
|
||||
prepareResult = result;
|
||||
transition(ProcessingState.loading);
|
||||
if (player.getShuffleModeEnabled()) {
|
||||
setShuffleOrder(mediaSource, 0);
|
||||
}
|
||||
this.mediaSource = mediaSource;
|
||||
player.prepare(mediaSource);
|
||||
}
|
||||
|
||||
private void ensurePlayerInitialized() {
|
||||
if (player == null) {
|
||||
player = new SimpleExoPlayer.Builder(context).build();
|
||||
player.addMetadataOutput(this);
|
||||
player.addListener(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastPlaybackEvent() {
|
||||
final Map<String, Object> event = new HashMap<String, Object>();
|
||||
event.put("processingState", processingState.ordinal());
|
||||
event.put("updatePosition", updatePosition = getCurrentPosition());
|
||||
event.put("updateTime", updateTime = System.currentTimeMillis());
|
||||
event.put("bufferedPosition", Math.max(updatePosition, bufferedPosition));
|
||||
event.put("icyMetadata", collectIcyMetadata());
|
||||
event.put("duration", duration = getDuration());
|
||||
event.put("currentIndex", currentIndex);
|
||||
|
||||
if (eventSink != null) {
|
||||
eventSink.success(event);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> collectIcyMetadata() {
|
||||
final Map<String, Object> icyData = new HashMap<>();
|
||||
if (icyInfo != null) {
|
||||
final Map<String, String> info = new HashMap<>();
|
||||
info.put("title", icyInfo.title);
|
||||
info.put("url", icyInfo.url);
|
||||
icyData.put("info", info);
|
||||
}
|
||||
if (icyHeaders != null) {
|
||||
final Map<String, Object> headers = new HashMap<>();
|
||||
headers.put("bitrate", icyHeaders.bitrate);
|
||||
headers.put("genre", icyHeaders.genre);
|
||||
headers.put("name", icyHeaders.name);
|
||||
headers.put("metadataInterval", icyHeaders.metadataInterval);
|
||||
headers.put("url", icyHeaders.url);
|
||||
headers.put("isPublic", icyHeaders.isPublic);
|
||||
icyData.put("headers", headers);
|
||||
}
|
||||
return icyData;
|
||||
}
|
||||
|
||||
private long getCurrentPosition() {
|
||||
if (processingState == ProcessingState.none || processingState == ProcessingState.loading) {
|
||||
return 0;
|
||||
} else if (seekPos != null && seekPos != C.TIME_UNSET) {
|
||||
return seekPos;
|
||||
} else {
|
||||
return player.getCurrentPosition();
|
||||
}
|
||||
}
|
||||
|
||||
private long getDuration() {
|
||||
if (processingState == ProcessingState.none || processingState == ProcessingState.loading) {
|
||||
return C.TIME_UNSET;
|
||||
} else {
|
||||
return player.getDuration();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendError(String errorCode, String errorMsg) {
|
||||
if (prepareResult != null) {
|
||||
prepareResult.error(errorCode, errorMsg, null);
|
||||
prepareResult = null;
|
||||
}
|
||||
|
||||
if (eventSink != null) {
|
||||
eventSink.error(errorCode, errorMsg, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void transition(final ProcessingState newState) {
|
||||
processingState = newState;
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
|
||||
private String getLowerCaseExtension(Uri uri) {
|
||||
// Until ExoPlayer provides automatic detection of media source types, we
|
||||
// rely on the file extension. When this is absent, as a temporary
|
||||
// workaround we allow the app to supply a fake extension in the URL
|
||||
// fragment. e.g. https://somewhere.com/somestream?x=etc#.m3u8
|
||||
String fragment = uri.getFragment();
|
||||
String filename = fragment != null && fragment.contains(".") ? fragment : uri.getPath();
|
||||
return filename.replaceAll("^.*\\.", "").toLowerCase();
|
||||
}
|
||||
|
||||
public void play(Result result) {
|
||||
if (player.getPlayWhenReady()) return;
|
||||
if (playResult != null) {
|
||||
playResult.success(null);
|
||||
}
|
||||
playResult = result;
|
||||
startWatchingBuffer();
|
||||
player.setPlayWhenReady(true);
|
||||
if (processingState == ProcessingState.completed && playResult != null) {
|
||||
playResult.success(null);
|
||||
playResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void pause() {
|
||||
if (!player.getPlayWhenReady()) return;
|
||||
player.setPlayWhenReady(false);
|
||||
if (playResult != null) {
|
||||
playResult.success(null);
|
||||
playResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setVolume(final float volume) {
|
||||
player.setVolume(volume);
|
||||
}
|
||||
|
||||
public void setSpeed(final float speed) {
|
||||
player.setPlaybackParameters(new PlaybackParameters(speed));
|
||||
broadcastPlaybackEvent();
|
||||
}
|
||||
|
||||
public void setLoopMode(final int mode) {
|
||||
player.setRepeatMode(mode);
|
||||
}
|
||||
|
||||
public void setShuffleModeEnabled(final boolean enabled) {
|
||||
if (enabled) {
|
||||
setShuffleOrder(mediaSource, 0);
|
||||
}
|
||||
player.setShuffleModeEnabled(enabled);
|
||||
}
|
||||
|
||||
public void seek(final long position, final Result result, final Integer index) {
|
||||
if (processingState == ProcessingState.none || processingState == ProcessingState.loading) {
|
||||
return;
|
||||
}
|
||||
abortSeek();
|
||||
seekPos = position;
|
||||
seekResult = result;
|
||||
seekProcessed = false;
|
||||
int windowIndex = index != null ? index : player.getCurrentWindowIndex();
|
||||
player.seekTo(windowIndex, position);
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
mediaSources.clear();
|
||||
mediaSource = null;
|
||||
loopingChildren.clear();
|
||||
if (player != null) {
|
||||
player.release();
|
||||
player = null;
|
||||
transition(ProcessingState.none);
|
||||
}
|
||||
if (eventSink != null) {
|
||||
eventSink.endOfStream();
|
||||
}
|
||||
onDispose.run();
|
||||
}
|
||||
|
||||
private void abortSeek() {
|
||||
if (seekResult != null) {
|
||||
seekResult.success(null);
|
||||
seekResult = null;
|
||||
seekPos = null;
|
||||
seekProcessed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void abortExistingConnection() {
|
||||
sendError("abort", "Connection aborted");
|
||||
}
|
||||
|
||||
public static Long getLong(Object o) {
|
||||
return (o == null || o instanceof Long) ? (Long)o : new Long(((Integer)o).intValue());
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
none,
|
||||
loading,
|
||||
buffering,
|
||||
ready,
|
||||
completed
|
||||
}
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
package com.ryanheise.just_audio;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class DeezerDataSource implements HttpDataSource {
|
||||
HttpURLConnection connection;
|
||||
InputStream inputStream;
|
||||
int counter = 0;
|
||||
byte[] key;
|
||||
DataSpec dataSpec;
|
||||
|
||||
//Quality fallback stuff
|
||||
String trackId;
|
||||
int quality = 0;
|
||||
String md5origin;
|
||||
String mediaVersion;
|
||||
|
||||
public DeezerDataSource(String trackId) {
|
||||
this.trackId = trackId;
|
||||
this.key = getKey(trackId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long open(DataSpec dataSpec) throws HttpDataSource.HttpDataSourceException {
|
||||
this.dataSpec = dataSpec;
|
||||
try {
|
||||
//Check if real url or placeholder for quality fallback
|
||||
URL url = new URL(dataSpec.uri.toString());
|
||||
String[] qp = url.getQuery().split("&");
|
||||
//Real deezcdn url doesnt have query params
|
||||
if (qp.length >= 3) {
|
||||
//Parse query parameters
|
||||
for (int i = 0; i < qp.length; i++) {
|
||||
String p = qp[i].replace("?", "");
|
||||
if (p.startsWith("md5")) {
|
||||
this.md5origin = p.replace("md5=", "");
|
||||
}
|
||||
if (p.startsWith("mv")) {
|
||||
this.mediaVersion = p.replace("mv=", "");
|
||||
}
|
||||
if (p.startsWith("q")) {
|
||||
if (this.quality == 0) {
|
||||
this.quality = Integer.parseInt(p.replace("q=", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
//Get real url
|
||||
url = new URL(this.getTrackUrl(trackId, md5origin, mediaVersion, quality));
|
||||
}
|
||||
|
||||
|
||||
this.connection = (HttpURLConnection) url.openConnection();
|
||||
this.connection.setChunkedStreamingMode(2048);
|
||||
if (dataSpec.position > 0) {
|
||||
this.counter = (int) (dataSpec.position/2048);
|
||||
this.connection.setRequestProperty("Range",
|
||||
"bytes=" + Long.toString(this.counter*2048) + "-");
|
||||
}
|
||||
|
||||
InputStream is = this.connection.getInputStream();
|
||||
this.inputStream = new BufferedInputStream(new FilterInputStream(is) {
|
||||
@Override
|
||||
public int read(byte buffer[], int offset, int len) throws IOException {
|
||||
byte[] b = new byte[2048];
|
||||
int t = 0;
|
||||
int read = 0;
|
||||
while (read != -1 && t != 2048) {
|
||||
t += read = in.read(b, t, 2048-t);
|
||||
}
|
||||
|
||||
if (counter % 3 == 0) {
|
||||
byte[] dec = decryptChunk(key, b);
|
||||
System.arraycopy(dec, 0, buffer, offset, 2048);
|
||||
} else {
|
||||
System.arraycopy(b, 0, buffer, offset, 2048);
|
||||
}
|
||||
counter++;
|
||||
|
||||
return t;
|
||||
|
||||
}
|
||||
},2048);
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
//Quality fallback
|
||||
if (this.quality == 1) {
|
||||
Log.e("E", e.toString());
|
||||
throw new HttpDataSourceException("Error loading URL", dataSpec, HttpDataSourceException.TYPE_OPEN);
|
||||
}
|
||||
if (this.quality == 3) this.quality = 1;
|
||||
if (this.quality == 9) this.quality = 3;
|
||||
// r e c u r s i o n
|
||||
return this.open(dataSpec);
|
||||
}
|
||||
String size = this.connection.getHeaderField("Content-Length");
|
||||
return Long.parseLong(size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws HttpDataSourceException {
|
||||
int read = 0;
|
||||
try {
|
||||
read = this.inputStream.read(buffer, offset, length);
|
||||
} catch (Exception e) {
|
||||
Log.e("E", e.toString());
|
||||
//throw new HttpDataSourceException("Error reading from stream", this.dataSpec, HttpDataSourceException.TYPE_READ);
|
||||
}
|
||||
return read;
|
||||
}
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (this.inputStream != null) this.inputStream.close();
|
||||
if (this.connection != null) this.connection.disconnect();
|
||||
} catch (Exception e) {
|
||||
Log.e("E", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String name, String value) {
|
||||
Log.d("D", "setRequestProperty");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearRequestProperty(String name) {
|
||||
Log.d("D", "clearRequestProperty");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAllRequestProperties() {
|
||||
Log.d("D", "clearAllRequestProperties");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
Log.d("D", "getResponseCode");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getResponseHeaders() {
|
||||
return this.connection.getHeaderFields();
|
||||
}
|
||||
|
||||
public final void addTransferListener(TransferListener transferListener) {
|
||||
Log.d("D", "addTransferListener");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return Uri.parse(this.connection.getURL().toString());
|
||||
}
|
||||
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
byte[] getKey(String id) {
|
||||
String secret = "g4el58wc0zvf9na1";
|
||||
try {
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
md5.update(id.getBytes());
|
||||
byte[] md5id = md5.digest();
|
||||
String idmd5 = bytesToHex(md5id).toLowerCase();
|
||||
String key = "";
|
||||
for(int i=0; i<16; i++) {
|
||||
int s0 = idmd5.charAt(i);
|
||||
int s1 = idmd5.charAt(i+16);
|
||||
int s2 = secret.charAt(i);
|
||||
key += (char)(s0^s1^s2);
|
||||
}
|
||||
return key.getBytes();
|
||||
} catch (Exception e) {
|
||||
Log.e("E", e.toString());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
byte[] decryptChunk(byte[] key, byte[] data) {
|
||||
try {
|
||||
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
|
||||
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
|
||||
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
|
||||
return cipher.doFinal(data);
|
||||
}catch (Exception e) {
|
||||
Log.e("D", e.toString());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
public String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) {
|
||||
try {
|
||||
int magic = 164;
|
||||
|
||||
ByteArrayOutputStream step1 = new ByteArrayOutputStream();
|
||||
step1.write(md5origin.getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(Integer.toString(quality).getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(trackId.getBytes());
|
||||
step1.write(magic);
|
||||
step1.write(mediaVersion.getBytes());
|
||||
//Get MD5
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
md5.update(step1.toByteArray());
|
||||
byte[] digest = md5.digest();
|
||||
String md5hex = bytesToHex(digest).toLowerCase();
|
||||
|
||||
ByteArrayOutputStream step2 = new ByteArrayOutputStream();
|
||||
step2.write(md5hex.getBytes());
|
||||
step2.write(magic);
|
||||
step2.write(step1.toByteArray());
|
||||
step2.write(magic);
|
||||
|
||||
//Pad step2 with dots, to get correct length
|
||||
while(step2.size()%16 > 0) step2.write(46);
|
||||
|
||||
//Prepare AES encryption
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
SecretKeySpec key = new SecretKeySpec("jo6aey6haid2Teih".getBytes(), "AES");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
//Encrypt
|
||||
StringBuilder step3 = new StringBuilder();
|
||||
for (int i=0; i<step2.size()/16; i++) {
|
||||
byte[] b = Arrays.copyOfRange(step2.toByteArray(), i*16, (i+1)*16);
|
||||
step3.append(bytesToHex(cipher.doFinal(b)).toLowerCase());
|
||||
}
|
||||
//Join to URL
|
||||
return "https://e-cdns-proxy-" + md5origin.charAt(0) + ".dzcdn.net/mobile/1/" + step3.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package com.ryanheise.just_audio;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin;
|
||||
import io.flutter.plugin.common.BinaryMessenger;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugin.common.PluginRegistry.Registrar;
|
||||
|
||||
/**
|
||||
* JustAudioPlugin
|
||||
*/
|
||||
public class JustAudioPlugin implements FlutterPlugin {
|
||||
|
||||
private MethodChannel channel;
|
||||
private MainMethodCallHandler methodCallHandler;
|
||||
|
||||
public JustAudioPlugin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 plugin registration.
|
||||
*/
|
||||
public static void registerWith(Registrar registrar) {
|
||||
final JustAudioPlugin plugin = new JustAudioPlugin();
|
||||
plugin.startListening(registrar.context(), registrar.messenger());
|
||||
registrar.addViewDestroyListener(
|
||||
view -> {
|
||||
plugin.stopListening();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
|
||||
startListening(binding.getApplicationContext(), binding.getBinaryMessenger());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
|
||||
stopListening();
|
||||
}
|
||||
|
||||
private void startListening(Context applicationContext, BinaryMessenger messenger) {
|
||||
methodCallHandler = new MainMethodCallHandler(applicationContext, messenger);
|
||||
|
||||
channel = new MethodChannel(messenger, "com.ryanheise.just_audio.methods");
|
||||
channel.setMethodCallHandler(methodCallHandler);
|
||||
}
|
||||
|
||||
private void stopListening() {
|
||||
methodCallHandler.dispose();
|
||||
methodCallHandler = null;
|
||||
|
||||
channel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package com.ryanheise.just_audio;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.plugin.common.BinaryMessenger;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
|
||||
import io.flutter.plugin.common.MethodChannel.Result;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
public class MainMethodCallHandler implements MethodCallHandler {
|
||||
|
||||
private final Context applicationContext;
|
||||
private final BinaryMessenger messenger;
|
||||
|
||||
private final Map<String, AudioPlayer> players = new HashMap<>();
|
||||
|
||||
public MainMethodCallHandler(Context applicationContext,
|
||||
BinaryMessenger messenger) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.messenger = messenger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, @NonNull Result result) {
|
||||
switch (call.method) {
|
||||
case "init":
|
||||
final List<String> ids = call.arguments();
|
||||
String id = ids.get(0);
|
||||
players.put(id, new AudioPlayer(applicationContext, messenger, id,
|
||||
() -> players.remove(id)
|
||||
));
|
||||
result.success(null);
|
||||
break;
|
||||
case "setIosCategory":
|
||||
result.success(null);
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (AudioPlayer player : new ArrayList<AudioPlayer>(players.values())) {
|
||||
player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue