mirror of
https://github.com/samsonjs/media.git
synced 2026-04-27 15:07:40 +00:00
Add minimal support of DVB AIT
This is used by broadcast channels to provide interactive contents.
This commit is contained in:
parent
8da0e27d2e
commit
1d65afdd16
6 changed files with 302 additions and 2 deletions
|
|
@ -97,6 +97,7 @@ public final class MimeTypes {
|
||||||
public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs";
|
public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs";
|
||||||
public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif";
|
public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif";
|
||||||
public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy";
|
public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy";
|
||||||
|
public static final String APPLICATION_AIT = BASE_TYPE_APPLICATION + "/ait";
|
||||||
|
|
||||||
private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>();
|
private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ package com.google.android.exoplayer2.metadata;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import com.google.android.exoplayer2.Format;
|
import com.google.android.exoplayer2.Format;
|
||||||
|
import com.google.android.exoplayer2.metadata.dvbsi.AitDecoder;
|
||||||
import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder;
|
import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder;
|
||||||
import com.google.android.exoplayer2.metadata.icy.IcyDecoder;
|
import com.google.android.exoplayer2.metadata.icy.IcyDecoder;
|
||||||
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
|
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
|
||||||
|
|
@ -67,7 +68,8 @@ public interface MetadataDecoderFactory {
|
||||||
return MimeTypes.APPLICATION_ID3.equals(mimeType)
|
return MimeTypes.APPLICATION_ID3.equals(mimeType)
|
||||||
|| MimeTypes.APPLICATION_EMSG.equals(mimeType)
|
|| MimeTypes.APPLICATION_EMSG.equals(mimeType)
|
||||||
|| MimeTypes.APPLICATION_SCTE35.equals(mimeType)
|
|| MimeTypes.APPLICATION_SCTE35.equals(mimeType)
|
||||||
|| MimeTypes.APPLICATION_ICY.equals(mimeType);
|
|| MimeTypes.APPLICATION_ICY.equals(mimeType)
|
||||||
|
|| MimeTypes.APPLICATION_AIT.equals(mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -83,6 +85,8 @@ public interface MetadataDecoderFactory {
|
||||||
return new SpliceInfoDecoder();
|
return new SpliceInfoDecoder();
|
||||||
case MimeTypes.APPLICATION_ICY:
|
case MimeTypes.APPLICATION_ICY:
|
||||||
return new IcyDecoder();
|
return new IcyDecoder();
|
||||||
|
case MimeTypes.APPLICATION_AIT:
|
||||||
|
return new AitDecoder();
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.google.android.exoplayer2.metadata.dvbsi;
|
||||||
|
|
||||||
|
import android.os.Parcel;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.metadata.Metadata;
|
||||||
|
|
||||||
|
public class Ait implements Metadata.Entry {
|
||||||
|
/*
|
||||||
|
The application shall be started when the service is selected, unless the
|
||||||
|
application is already running.
|
||||||
|
*/
|
||||||
|
public static final int CONTROL_CODE_AUTOSTART = 0x01;
|
||||||
|
/*
|
||||||
|
The application is allowed to run while the service is selected, however it
|
||||||
|
shall not start automatically when the service becomes selected.
|
||||||
|
*/
|
||||||
|
public static final int CONTROL_CODE_PRESENT = 0x02;
|
||||||
|
|
||||||
|
public final int controlCode;
|
||||||
|
public final String url;
|
||||||
|
|
||||||
|
Ait(int controlCode, String url) {
|
||||||
|
this.controlCode = controlCode;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Ait(controlCode = " + controlCode + ", url = " + url + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int describeContents() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeToParcel(Parcel parcel, int i) {
|
||||||
|
parcel.writeString(url);
|
||||||
|
parcel.writeInt(controlCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Parcelable.Creator<Ait> CREATOR =
|
||||||
|
new Parcelable.Creator<Ait>() {
|
||||||
|
@Override
|
||||||
|
public Ait createFromParcel(Parcel in) {
|
||||||
|
String url = in.readString();
|
||||||
|
int controlCode = in.readInt();
|
||||||
|
return new Ait(controlCode, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Ait[] newArray(int size) {
|
||||||
|
return new Ait[size];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package com.google.android.exoplayer2.metadata.dvbsi;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.metadata.Metadata;
|
||||||
|
import com.google.android.exoplayer2.metadata.MetadataDecoder;
|
||||||
|
import com.google.android.exoplayer2.metadata.MetadataInputBuffer;
|
||||||
|
import com.google.android.exoplayer2.util.ParsableByteArray;
|
||||||
|
import com.google.android.exoplayer2.util.TimestampAdjuster;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
public class AitDecoder implements MetadataDecoder {
|
||||||
|
// Specification of AIT can be found in 5.3.4 of TS 102 809 v1.1.1
|
||||||
|
// https://www.etsi.org/deliver/etsi_ts/102800_102899/102809/01.01.01_60/ts_102809v010101p.pdf
|
||||||
|
private final static int DESCRIPTOR_TRANSPORT_PROTOCOL = 0x02;
|
||||||
|
|
||||||
|
private final static int DESCRIPTOR_SIMPLE_APPLICATION_LOCATION = 0x15;
|
||||||
|
|
||||||
|
private final static int TRANSPORT_PROTOCOL_HTTP = 3;
|
||||||
|
|
||||||
|
private TimestampAdjuster timestampAdjuster;
|
||||||
|
|
||||||
|
private final ParsableByteArray sectionData;
|
||||||
|
|
||||||
|
public AitDecoder() {
|
||||||
|
sectionData = new ParsableByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Metadata decode(MetadataInputBuffer inputBuffer) {
|
||||||
|
if (timestampAdjuster == null
|
||||||
|
|| inputBuffer.subsampleOffsetUs != timestampAdjuster.getTimestampOffsetUs()) {
|
||||||
|
timestampAdjuster = new TimestampAdjuster(inputBuffer.timeUs);
|
||||||
|
timestampAdjuster.adjustSampleTimestamp(inputBuffer.timeUs - inputBuffer.subsampleOffsetUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer buffer = inputBuffer.data;
|
||||||
|
byte[] data = buffer.array();
|
||||||
|
int size = buffer.limit();
|
||||||
|
sectionData.reset(data, size);
|
||||||
|
|
||||||
|
int tableId = sectionData.peekUnsignedByte();
|
||||||
|
|
||||||
|
//Only this table is allowed in AIT streams
|
||||||
|
if (tableId == 0x74) {
|
||||||
|
return parseAit(sectionData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Metadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Metadata parseAit(ParsableByteArray sectionData) {
|
||||||
|
int tmp;
|
||||||
|
|
||||||
|
int tableId = sectionData.readUnsignedByte();
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedShort();
|
||||||
|
int endOfSection = sectionData.getPosition() + (tmp & 4095) - 4 /* Ignore leading CRC */;
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedShort();
|
||||||
|
int applicationType = tmp & 0x7fff;
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedByte();
|
||||||
|
int versionNumber = (tmp & 0x3e) >> 1;
|
||||||
|
boolean current = (tmp & 1) == 1;
|
||||||
|
|
||||||
|
int section_number = sectionData.readUnsignedByte();
|
||||||
|
int last_section_number = sectionData.readUnsignedByte();
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedShort();
|
||||||
|
int commonDescriptorsLength = tmp & 4095;
|
||||||
|
|
||||||
|
//Since we currently only keep url and control code, which are unique per application,
|
||||||
|
//there is no useful information in common descriptor.
|
||||||
|
sectionData.skipBytes(commonDescriptorsLength);
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedShort();
|
||||||
|
int appLoopLength = tmp & 4095;
|
||||||
|
|
||||||
|
ArrayList<Ait> aits = new ArrayList<>();
|
||||||
|
while(sectionData.getPosition() < endOfSection) {
|
||||||
|
// Values that will be stored in Ait()
|
||||||
|
String aitUrlBase = null;
|
||||||
|
String aitUrlExtension = null;
|
||||||
|
int aitControlCode = -1;
|
||||||
|
|
||||||
|
long application_identifier = sectionData.readUnsignedInt24() << 24L;
|
||||||
|
application_identifier |= sectionData.readUnsignedInt24();
|
||||||
|
int controlCode = sectionData.readUnsignedByte();
|
||||||
|
|
||||||
|
aitControlCode = controlCode;
|
||||||
|
|
||||||
|
tmp = sectionData.readUnsignedShort();
|
||||||
|
int sectionLength = tmp & 4095;
|
||||||
|
int positionOfNextSection = sectionData.getPosition() + sectionLength;
|
||||||
|
while(sectionData.getPosition() < positionOfNextSection) {
|
||||||
|
int type = sectionData.readUnsignedByte();
|
||||||
|
int l = sectionData.readUnsignedByte();
|
||||||
|
int positionOfNextSection2 = sectionData.getPosition() + l;
|
||||||
|
|
||||||
|
if(type == DESCRIPTOR_TRANSPORT_PROTOCOL) {
|
||||||
|
int protocolId = sectionData.readUnsignedShort();
|
||||||
|
int label = sectionData.readUnsignedByte();
|
||||||
|
|
||||||
|
if(protocolId == TRANSPORT_PROTOCOL_HTTP) {
|
||||||
|
while (sectionData.getPosition() < positionOfNextSection2) {
|
||||||
|
int urlBaseLength = sectionData.readUnsignedByte();
|
||||||
|
String urlBase = sectionData.readString(urlBaseLength);
|
||||||
|
int extensionCount = sectionData.readUnsignedByte();
|
||||||
|
aitUrlBase = urlBase;
|
||||||
|
for (int i = 0; i < extensionCount; i++) {
|
||||||
|
int len = sectionData.readUnsignedByte();
|
||||||
|
sectionData.skipBytes(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if(type == DESCRIPTOR_SIMPLE_APPLICATION_LOCATION) {
|
||||||
|
String url = sectionData.readString(l);
|
||||||
|
aitUrlExtension = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionData.setPosition(positionOfNextSection2);
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionData.setPosition(positionOfNextSection);
|
||||||
|
|
||||||
|
if(aitControlCode != -1 && aitUrlBase != null && aitUrlExtension != null) {
|
||||||
|
aits.add(new Ait(aitControlCode, aitUrlBase + aitUrlExtension));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Metadata(aits);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final String dvbCharset[] = {
|
||||||
|
"ISO-8859-15",
|
||||||
|
"ISO-8859-5",
|
||||||
|
"ISO-8859-6",
|
||||||
|
"ISO-8859-7",
|
||||||
|
"ISO-8859-8",
|
||||||
|
"ISO-8859-9",
|
||||||
|
null,
|
||||||
|
"ISO-8859-11",
|
||||||
|
null,
|
||||||
|
"ISO-8859-13",
|
||||||
|
null,
|
||||||
|
"ISO-8859-15",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
//0x10
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String readDvbString(ParsableByteArray data, int length) {
|
||||||
|
if(length == 0) return null;
|
||||||
|
int charsetSelect = data.peekUnsignedByte();
|
||||||
|
if(charsetSelect >= 0x20)
|
||||||
|
return data.readString(length, Charset.forName("ISO-8859-15"));
|
||||||
|
data.skipBytes(1);
|
||||||
|
if(charsetSelect == 0x1f) {
|
||||||
|
data.skipBytes(length - 1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if(charsetSelect != 0x10)
|
||||||
|
return data.readString(length-1, Charset.forName(dvbCharset[charsetSelect]));
|
||||||
|
if(length == 2) {
|
||||||
|
data.skipBytes(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
charsetSelect = data.readUnsignedShort();
|
||||||
|
if(charsetSelect > 1 && charsetSelect < 0x10) {
|
||||||
|
String charsetName = "ISO-8859-" + charsetSelect;
|
||||||
|
return data.readString(length-3, Charset.forName(charsetName));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,17 @@ package com.google.android.exoplayer2.extractor.ts;
|
||||||
import android.util.SparseArray;
|
import android.util.SparseArray;
|
||||||
import androidx.annotation.IntDef;
|
import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.C;
|
||||||
import com.google.android.exoplayer2.Format;
|
import com.google.android.exoplayer2.Format;
|
||||||
|
import com.google.android.exoplayer2.extractor.ExtractorOutput;
|
||||||
|
import com.google.android.exoplayer2.extractor.TrackOutput;
|
||||||
import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo;
|
import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo;
|
||||||
import com.google.android.exoplayer2.util.CodecSpecificDataUtil;
|
import com.google.android.exoplayer2.util.CodecSpecificDataUtil;
|
||||||
import com.google.android.exoplayer2.util.MimeTypes;
|
import com.google.android.exoplayer2.util.MimeTypes;
|
||||||
import com.google.android.exoplayer2.util.ParsableByteArray;
|
import com.google.android.exoplayer2.util.ParsableByteArray;
|
||||||
|
import com.google.android.exoplayer2.util.TimestampAdjuster;
|
||||||
|
|
||||||
import java.lang.annotation.Documented;
|
import java.lang.annotation.Documented;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
@ -176,11 +182,41 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact
|
||||||
case TsExtractor.TS_STREAM_TYPE_DVBSUBS:
|
case TsExtractor.TS_STREAM_TYPE_DVBSUBS:
|
||||||
return new PesReader(
|
return new PesReader(
|
||||||
new DvbSubtitleReader(esInfo.dvbSubtitleInfos));
|
new DvbSubtitleReader(esInfo.dvbSubtitleInfos));
|
||||||
|
case TsExtractor.TS_STREAM_TYPE_AIT:
|
||||||
|
return new SectionReader(new SectionPassthrough(MimeTypes.APPLICATION_AIT));
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SectionPassthrough implements SectionPayloadReader {
|
||||||
|
private TimestampAdjuster timestampAdjuster = null;
|
||||||
|
private final String mimeType;
|
||||||
|
private TrackOutput output;
|
||||||
|
|
||||||
|
SectionPassthrough(String mimeType) {
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator) {
|
||||||
|
this.timestampAdjuster = timestampAdjuster;
|
||||||
|
idGenerator.generateNewId();
|
||||||
|
output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_METADATA);
|
||||||
|
output.format(Format.createSampleFormat(null, mimeType,
|
||||||
|
timestampAdjuster.getTimestampOffsetUs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(ParsableByteArray sectionData) {
|
||||||
|
int sampleSize = sectionData.bytesLeft();
|
||||||
|
output.sampleData(sectionData, sampleSize);
|
||||||
|
output.sampleMetadata(timestampAdjuster.getLastAdjustedTimestampUs(), C.BUFFER_FLAG_KEY_FRAME,
|
||||||
|
sampleSize, 0, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, returns a {@link SeiReader} for
|
* If {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, returns a {@link SeiReader} for
|
||||||
* {@link #closedCaptionFormats}. If unset, parses the PMT descriptor information and returns a
|
* {@link #closedCaptionFormats}. If unset, parses the PMT descriptor information and returns a
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ public final class TsExtractor implements Extractor {
|
||||||
public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86;
|
public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86;
|
||||||
public static final int TS_STREAM_TYPE_DVBSUBS = 0x59;
|
public static final int TS_STREAM_TYPE_DVBSUBS = 0x59;
|
||||||
|
|
||||||
|
//Those are special IDs, which don't have actual TS definitions
|
||||||
|
public static final int TS_STREAM_TYPE_AIT = 0x101;
|
||||||
|
|
||||||
public static final int TS_PACKET_SIZE = 188;
|
public static final int TS_PACKET_SIZE = 188;
|
||||||
public static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet.
|
public static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet.
|
||||||
|
|
||||||
|
|
@ -494,6 +497,7 @@ public final class TsExtractor implements Extractor {
|
||||||
private static final int TS_PMT_DESC_REGISTRATION = 0x05;
|
private static final int TS_PMT_DESC_REGISTRATION = 0x05;
|
||||||
private static final int TS_PMT_DESC_ISO639_LANG = 0x0A;
|
private static final int TS_PMT_DESC_ISO639_LANG = 0x0A;
|
||||||
private static final int TS_PMT_DESC_AC3 = 0x6A;
|
private static final int TS_PMT_DESC_AC3 = 0x6A;
|
||||||
|
private static final int TS_PMT_DESC_AIT = 0x6F;
|
||||||
private static final int TS_PMT_DESC_EAC3 = 0x7A;
|
private static final int TS_PMT_DESC_EAC3 = 0x7A;
|
||||||
private static final int TS_PMT_DESC_DTS = 0x7B;
|
private static final int TS_PMT_DESC_DTS = 0x7B;
|
||||||
private static final int TS_PMT_DESC_DVB_EXT = 0x7F;
|
private static final int TS_PMT_DESC_DVB_EXT = 0x7F;
|
||||||
|
|
@ -578,7 +582,7 @@ public final class TsExtractor implements Extractor {
|
||||||
pmtScratch.skipBits(4); // reserved
|
pmtScratch.skipBits(4); // reserved
|
||||||
int esInfoLength = pmtScratch.readBits(12); // ES_info_length.
|
int esInfoLength = pmtScratch.readBits(12); // ES_info_length.
|
||||||
EsInfo esInfo = readEsInfo(sectionData, esInfoLength);
|
EsInfo esInfo = readEsInfo(sectionData, esInfoLength);
|
||||||
if (streamType == 0x06) {
|
if (streamType == 0x06 || streamType == 0x05) {
|
||||||
streamType = esInfo.streamType;
|
streamType = esInfo.streamType;
|
||||||
}
|
}
|
||||||
remainingEntriesLength -= esInfoLength + 5;
|
remainingEntriesLength -= esInfoLength + 5;
|
||||||
|
|
@ -688,6 +692,8 @@ public final class TsExtractor implements Extractor {
|
||||||
dvbSubtitleInfos.add(new DvbSubtitleInfo(dvbLanguage, dvbSubtitlingType,
|
dvbSubtitleInfos.add(new DvbSubtitleInfo(dvbLanguage, dvbSubtitlingType,
|
||||||
initializationData));
|
initializationData));
|
||||||
}
|
}
|
||||||
|
} else if (descriptorTag == TS_PMT_DESC_AIT) {
|
||||||
|
streamType = TS_STREAM_TYPE_AIT;
|
||||||
}
|
}
|
||||||
// Skip unused bytes of current descriptor.
|
// Skip unused bytes of current descriptor.
|
||||||
data.skipBytes(positionOfNextDescriptor - data.getPosition());
|
data.skipBytes(positionOfNextDescriptor - data.getPosition());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue