Commit b0a3c30a by Oliver Woodman

Improve EIA608 caption support.

- Also make text renderers respect the decodeOnly flag.
- Also fix AC3 passthrough to always allocate direct buffers.
parent 32f0eb12
...@@ -25,7 +25,6 @@ import android.media.AudioFormat; ...@@ -25,7 +25,6 @@ import android.media.AudioFormat;
import android.os.Handler; import android.os.Handler;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
/** /**
* Renders encoded AC-3/enhanced AC-3 data to an {@link AudioTrack} for decoding on the playback * Renders encoded AC-3/enhanced AC-3 data to an {@link AudioTrack} for decoding on the playback
...@@ -105,8 +104,8 @@ public final class Ac3PassthroughAudioTrackRenderer extends TrackRenderer { ...@@ -105,8 +104,8 @@ public final class Ac3PassthroughAudioTrackRenderer extends TrackRenderer {
this.source = Assertions.checkNotNull(source); this.source = Assertions.checkNotNull(source);
this.eventHandler = eventHandler; this.eventHandler = eventHandler;
this.eventListener = eventListener; this.eventListener = eventListener;
sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); sampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
sampleHolder.data = ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE); sampleHolder.replaceBuffer(DEFAULT_BUFFER_SIZE);
formatHolder = new MediaFormatHolder(); formatHolder = new MediaFormatHolder();
audioTrack = new AudioTrack(); audioTrack = new AudioTrack();
shouldReadInputBuffer = true; shouldReadInputBuffer = true;
...@@ -199,8 +198,7 @@ public final class Ac3PassthroughAudioTrackRenderer extends TrackRenderer { ...@@ -199,8 +198,7 @@ public final class Ac3PassthroughAudioTrackRenderer extends TrackRenderer {
// Get more data if we have run out. // Get more data if we have run out.
if (shouldReadInputBuffer) { if (shouldReadInputBuffer) {
sampleHolder.data.clear(); sampleHolder.clearData();
int result = int result =
source.readData(trackIndex, currentPositionUs, formatHolder, sampleHolder, false); source.readData(trackIndex, currentPositionUs, formatHolder, sampleHolder, false);
if (result == SampleSource.FORMAT_READ) { if (result == SampleSource.FORMAT_READ) {
......
...@@ -96,4 +96,13 @@ public final class SampleHolder { ...@@ -96,4 +96,13 @@ public final class SampleHolder {
return false; return false;
} }
/**
* Clears {@link #data}. Does nothing if {@link #data} is null.
*/
public void clearData() {
if (data != null) {
data.clear();
}
}
} }
...@@ -34,7 +34,9 @@ import android.util.SparseArray; ...@@ -34,7 +34,9 @@ import android.util.SparseArray;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
/** /**
...@@ -541,7 +543,6 @@ public final class TsExtractor { ...@@ -541,7 +543,6 @@ public final class TsExtractor {
@SuppressWarnings("hiding") @SuppressWarnings("hiding")
private final SamplePool samplePool; private final SamplePool samplePool;
private final ConcurrentLinkedQueue<Sample> internalQueue;
// Accessed only by the consuming thread. // Accessed only by the consuming thread.
private boolean needKeyframe; private boolean needKeyframe;
...@@ -553,7 +554,6 @@ public final class TsExtractor { ...@@ -553,7 +554,6 @@ public final class TsExtractor {
protected SampleQueue(SamplePool samplePool) { protected SampleQueue(SamplePool samplePool) {
this.samplePool = samplePool; this.samplePool = samplePool;
internalQueue = new ConcurrentLinkedQueue<Sample>();
needKeyframe = true; needKeyframe = true;
lastReadTimeUs = Long.MIN_VALUE; lastReadTimeUs = Long.MIN_VALUE;
spliceOutTimeUs = Long.MIN_VALUE; spliceOutTimeUs = Long.MIN_VALUE;
...@@ -582,7 +582,7 @@ public final class TsExtractor { ...@@ -582,7 +582,7 @@ public final class TsExtractor {
public Sample poll() { public Sample poll() {
Sample head = peek(); Sample head = peek();
if (head != null) { if (head != null) {
internalQueue.remove(); internalPollSample();
needKeyframe = false; needKeyframe = false;
lastReadTimeUs = head.timeUs; lastReadTimeUs = head.timeUs;
} }
...@@ -595,13 +595,13 @@ public final class TsExtractor { ...@@ -595,13 +595,13 @@ public final class TsExtractor {
* @return The next sample from the queue, or null if a sample isn't available. * @return The next sample from the queue, or null if a sample isn't available.
*/ */
public Sample peek() { public Sample peek() {
Sample head = internalQueue.peek(); Sample head = internalPeekSample();
if (needKeyframe) { if (needKeyframe) {
// Peeking discard of samples until we find a keyframe or run out of available samples. // Peeking discard of samples until we find a keyframe or run out of available samples.
while (head != null && !head.isKeyframe) { while (head != null && !head.isKeyframe) {
recycle(head); recycle(head);
internalQueue.remove(); internalPollSample();
head = internalQueue.peek(); head = internalPeekSample();
} }
} }
if (head == null) { if (head == null) {
...@@ -610,7 +610,7 @@ public final class TsExtractor { ...@@ -610,7 +610,7 @@ public final class TsExtractor {
if (spliceOutTimeUs != Long.MIN_VALUE && head.timeUs >= spliceOutTimeUs) { if (spliceOutTimeUs != Long.MIN_VALUE && head.timeUs >= spliceOutTimeUs) {
// The sample is later than the time this queue is spliced out. // The sample is later than the time this queue is spliced out.
recycle(head); recycle(head);
internalQueue.remove(); internalPollSample();
return null; return null;
} }
return head; return head;
...@@ -625,8 +625,8 @@ public final class TsExtractor { ...@@ -625,8 +625,8 @@ public final class TsExtractor {
Sample head = peek(); Sample head = peek();
while (head != null && head.timeUs < timeUs) { while (head != null && head.timeUs < timeUs) {
recycle(head); recycle(head);
internalQueue.remove(); internalPollSample();
head = internalQueue.peek(); head = internalPeekSample();
// We're discarding at least one sample, so any subsequent read will need to start at // We're discarding at least one sample, so any subsequent read will need to start at
// a keyframe. // a keyframe.
needKeyframe = true; needKeyframe = true;
...@@ -638,10 +638,10 @@ public final class TsExtractor { ...@@ -638,10 +638,10 @@ public final class TsExtractor {
* Clears the queue. * Clears the queue.
*/ */
public void release() { public void release() {
Sample toRecycle = internalQueue.poll(); Sample toRecycle = internalPollSample();
while (toRecycle != null) { while (toRecycle != null) {
recycle(toRecycle); recycle(toRecycle);
toRecycle = internalQueue.poll(); toRecycle = internalPollSample();
} }
} }
...@@ -666,20 +666,19 @@ public final class TsExtractor { ...@@ -666,20 +666,19 @@ public final class TsExtractor {
return true; return true;
} }
long firstPossibleSpliceTime; long firstPossibleSpliceTime;
Sample nextSample = internalQueue.peek(); Sample nextSample = internalPeekSample();
if (nextSample != null) { if (nextSample != null) {
firstPossibleSpliceTime = nextSample.timeUs; firstPossibleSpliceTime = nextSample.timeUs;
} else { } else {
firstPossibleSpliceTime = lastReadTimeUs + 1; firstPossibleSpliceTime = lastReadTimeUs + 1;
} }
ConcurrentLinkedQueue<Sample> nextInternalQueue = nextQueue.internalQueue; Sample nextQueueSample = nextQueue.internalPeekSample();
Sample nextQueueSample = nextInternalQueue.peek();
while (nextQueueSample != null while (nextQueueSample != null
&& (nextQueueSample.timeUs < firstPossibleSpliceTime || !nextQueueSample.isKeyframe)) { && (nextQueueSample.timeUs < firstPossibleSpliceTime || !nextQueueSample.isKeyframe)) {
// Discard samples from the next queue for as long as they are before the earliest possible // Discard samples from the next queue for as long as they are before the earliest possible
// splice time, or not keyframes. // splice time, or not keyframes.
nextQueue.internalQueue.remove(); nextQueue.internalPollSample();
nextQueueSample = nextQueue.internalQueue.peek(); nextQueueSample = nextQueue.internalPeekSample();
} }
if (nextQueueSample != null) { if (nextQueueSample != null) {
// We've found a keyframe in the next queue that can serve as the splice point. Set the // We've found a keyframe in the next queue that can serve as the splice point. Set the
...@@ -720,7 +719,7 @@ public final class TsExtractor { ...@@ -720,7 +719,7 @@ public final class TsExtractor {
protected void addSample(Sample sample) { protected void addSample(Sample sample) {
largestParsedTimestampUs = Math.max(largestParsedTimestampUs, sample.timeUs); largestParsedTimestampUs = Math.max(largestParsedTimestampUs, sample.timeUs);
internalQueue.add(sample); internalQueueSample(sample);
} }
protected void addToSample(Sample sample, BitArray buffer, int size) { protected void addToSample(Sample sample, BitArray buffer, int size) {
...@@ -731,15 +730,37 @@ public final class TsExtractor { ...@@ -731,15 +730,37 @@ public final class TsExtractor {
sample.size += size; sample.size += size;
} }
protected abstract Sample internalPeekSample();
protected abstract Sample internalPollSample();
protected abstract void internalQueueSample(Sample sample);
} }
/** /**
* Extracts individual samples from continuous byte stream. * Extracts individual samples from continuous byte stream, preserving original order.
*/ */
private abstract class PesPayloadReader extends SampleQueue { private abstract class PesPayloadReader extends SampleQueue {
private final ConcurrentLinkedQueue<Sample> internalQueue;
protected PesPayloadReader(SamplePool samplePool) { protected PesPayloadReader(SamplePool samplePool) {
super(samplePool); super(samplePool);
internalQueue = new ConcurrentLinkedQueue<Sample>();
}
@Override
protected final Sample internalPeekSample() {
return internalQueue.peek();
}
@Override
protected final Sample internalPollSample() {
return internalQueue.poll();
}
@Override
protected final void internalQueueSample(Sample sample) {
internalQueue.add(sample);
} }
public abstract void read(BitArray pesBuffer, int pesPayloadSize, long pesTimeUs); public abstract void read(BitArray pesBuffer, int pesPayloadSize, long pesTimeUs);
...@@ -992,18 +1013,23 @@ public final class TsExtractor { ...@@ -992,18 +1013,23 @@ public final class TsExtractor {
/** /**
* Parses a SEI data from H.264 frames and extracts samples with closed captions data. * Parses a SEI data from H.264 frames and extracts samples with closed captions data.
*
* TODO: Technically, we shouldn't allow a sample to be read from the queue until we're sure that
* a sample with an earlier timestamp won't be added to it.
*/ */
private class SeiReader extends SampleQueue { private class SeiReader extends SampleQueue implements Comparator<Sample> {
// SEI data, used for Closed Captions. // SEI data, used for Closed Captions.
private static final int NAL_UNIT_TYPE_SEI = 6; private static final int NAL_UNIT_TYPE_SEI = 6;
private final BitArray seiBuffer; private final BitArray seiBuffer;
private final TreeSet<Sample> internalQueue;
public SeiReader(SamplePool samplePool) { public SeiReader(SamplePool samplePool) {
super(samplePool); super(samplePool);
setMediaFormat(MediaFormat.createEia608Format()); setMediaFormat(MediaFormat.createEia608Format());
seiBuffer = new BitArray(); seiBuffer = new BitArray();
internalQueue = new TreeSet<Sample>(this);
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
...@@ -1022,6 +1048,27 @@ public final class TsExtractor { ...@@ -1022,6 +1048,27 @@ public final class TsExtractor {
} }
} }
@Override
public int compare(Sample first, Sample second) {
// Note - We don't expect samples to have identical timestamps.
return first.timeUs <= second.timeUs ? -1 : 1;
}
@Override
protected synchronized Sample internalPeekSample() {
return internalQueue.isEmpty() ? null : internalQueue.first();
}
@Override
protected synchronized Sample internalPollSample() {
return internalQueue.pollFirst();
}
@Override
protected synchronized void internalQueueSample(Sample sample) {
internalQueue.add(sample);
}
} }
/** /**
......
...@@ -135,7 +135,6 @@ public class SubtitleParserHelper implements Handler.Callback { ...@@ -135,7 +135,6 @@ public class SubtitleParserHelper implements Handler.Callback {
if (sampleHolder != holder) { if (sampleHolder != holder) {
// A flush has occurred since this holder was posted. Do nothing. // A flush has occurred since this holder was posted. Do nothing.
} else { } else {
holder.data.position(0);
this.result = result; this.result = result;
this.error = error; this.error = error;
this.parsing = false; this.parsing = false;
......
...@@ -177,8 +177,9 @@ public class TextTrackRenderer extends TrackRenderer implements Callback { ...@@ -177,8 +177,9 @@ public class TextTrackRenderer extends TrackRenderer implements Callback {
if (!inputStreamEnded && subtitle == null) { if (!inputStreamEnded && subtitle == null) {
try { try {
SampleHolder sampleHolder = parserHelper.getSampleHolder(); SampleHolder sampleHolder = parserHelper.getSampleHolder();
sampleHolder.clearData();
int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false); int result = source.readData(trackIndex, positionUs, formatHolder, sampleHolder, false);
if (result == SampleSource.SAMPLE_READ) { if (result == SampleSource.SAMPLE_READ && !sampleHolder.decodeOnly) {
parserHelper.startParseOperation(); parserHelper.startParseOperation();
textRendererNeedsUpdate = false; textRendererNeedsUpdate = false;
} else if (result == SampleSource.END_OF_STREAM) { } else if (result == SampleSource.END_OF_STREAM) {
......
...@@ -18,7 +18,7 @@ package com.google.android.exoplayer.text.eia608; ...@@ -18,7 +18,7 @@ package com.google.android.exoplayer.text.eia608;
/** /**
* A Closed Caption that contains textual data associated with time indices. * A Closed Caption that contains textual data associated with time indices.
*/ */
public final class ClosedCaption implements Comparable<ClosedCaption> { /* package */ abstract class ClosedCaption implements Comparable<ClosedCaption> {
/** /**
* Identifies closed captions with control characters. * Identifies closed captions with control characters.
...@@ -30,23 +30,16 @@ public final class ClosedCaption implements Comparable<ClosedCaption> { ...@@ -30,23 +30,16 @@ public final class ClosedCaption implements Comparable<ClosedCaption> {
public static final int TYPE_TEXT = 1; public static final int TYPE_TEXT = 1;
/** /**
* The type of the closed caption data. If equals to {@link #TYPE_TEXT} the {@link #text} field * The type of the closed caption data.
* has the textual data, if equals to {@link #TYPE_CTRL} the {@link #text} field has two control
* characters (C1, C2).
*/ */
public final int type; public final int type;
/** /**
* Contains text or two control characters.
*/
public final String text;
/**
* Timestamp associated with the closed caption. * Timestamp associated with the closed caption.
*/ */
public final long timeUs; public final long timeUs;
public ClosedCaption(int type, String text, long timeUs) { protected ClosedCaption(int type, long timeUs) {
this.type = type; this.type = type;
this.text = text;
this.timeUs = timeUs; this.timeUs = timeUs;
} }
......
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.text.eia608;
/* package */ final class ClosedCaptionCtrl extends ClosedCaption {
/**
* The receipt of the {@link #RESUME_CAPTION_LOADING} command initiates pop-on style captioning.
* Subsequent data should be loaded into a non-displayed memory and held there until the
* {@link #END_OF_CAPTION} command is received, at which point the non-displayed memory becomes
* the displayed memory (and vice versa).
*/
public static final byte RESUME_CAPTION_LOADING = 0x20;
/**
* The receipt of the {@link #ROLL_UP_CAPTIONS_2_ROWS} command initiates roll-up style
* captioning, with the maximum of 2 rows displayed simultaneously.
*/
public static final byte ROLL_UP_CAPTIONS_2_ROWS = 0x25;
/**
* The receipt of the {@link #ROLL_UP_CAPTIONS_3_ROWS} command initiates roll-up style
* captioning, with the maximum of 3 rows displayed simultaneously.
*/
public static final byte ROLL_UP_CAPTIONS_3_ROWS = 0x26;
/**
* The receipt of the {@link #ROLL_UP_CAPTIONS_4_ROWS} command initiates roll-up style
* captioning, with the maximum of 4 rows displayed simultaneously.
*/
public static final byte ROLL_UP_CAPTIONS_4_ROWS = 0x27;
/**
* The receipt of the {@link #RESUME_DIRECT_CAPTIONING} command initiates paint-on style
* captioning. Subsequent data should be addressed immediately to displayed memory without need
* for the {@link #RESUME_CAPTION_LOADING} command.
*/
public static final byte RESUME_DIRECT_CAPTIONING = 0x29;
/**
* The receipt of the {@link #END_OF_CAPTION} command indicates the end of pop-on style caption,
* at this point already loaded in non-displayed memory caption should become the displayed
* memory (and vice versa). If no {@link #RESUME_CAPTION_LOADING} command has been received,
* {@link #END_OF_CAPTION} command forces the receiver into pop-on style.
*/
public static final byte END_OF_CAPTION = 0x2F;
public static final byte ERASE_DISPLAYED_MEMORY = 0x2C;
public static final byte CARRIAGE_RETURN = 0x2D;
public static final byte ERASE_NON_DISPLAYED_MEMORY = 0x2E;
public static final byte MID_ROW_CHAN_1 = 0x11;
public static final byte MID_ROW_CHAN_2 = 0x19;
public static final byte MISC_CHAN_1 = 0x14;
public static final byte MISC_CHAN_2 = 0x1C;
public static final byte TAB_OFFSET_CHAN_1 = 0x17;
public static final byte TAB_OFFSET_CHAN_2 = 0x1F;
public final byte cc1;
public final byte cc2;
protected ClosedCaptionCtrl(byte cc1, byte cc2, long timeUs) {
super(ClosedCaption.TYPE_CTRL, timeUs);
this.cc1 = cc1;
this.cc2 = cc2;
}
public boolean isMidRowCode() {
return (cc1 == MID_ROW_CHAN_1 || cc1 == MID_ROW_CHAN_2) && (cc2 >= 0x20 && cc2 <= 0x2F);
}
public boolean isMiscCode() {
return (cc1 == MISC_CHAN_1 || cc1 == MISC_CHAN_2) && (cc2 >= 0x20 && cc2 <= 0x2F);
}
public boolean isTabOffsetCode() {
return (cc1 == TAB_OFFSET_CHAN_1 || cc1 == TAB_OFFSET_CHAN_2) && (cc2 >= 0x21 && cc2 <= 0x23);
}
public boolean isPreambleAddressCode() {
return (cc1 >= 0x10 && cc1 <= 0x1F) && (cc2 >= 0x40 && cc2 <= 0x7F);
}
}
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer.text.eia608;
/* package */ final class ClosedCaptionText extends ClosedCaption {
public final String text;
public ClosedCaptionText(String text, long timeUs) {
super(ClosedCaption.TYPE_TEXT, timeUs);
this.text = text;
}
}
...@@ -18,8 +18,6 @@ package com.google.android.exoplayer.text.eia608; ...@@ -18,8 +18,6 @@ package com.google.android.exoplayer.text.eia608;
import com.google.android.exoplayer.util.BitArray; import com.google.android.exoplayer.util.BitArray;
import com.google.android.exoplayer.util.MimeTypes; import com.google.android.exoplayer.util.MimeTypes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
...@@ -82,22 +80,29 @@ public class Eia608Parser { ...@@ -82,22 +80,29 @@ public class Eia608Parser {
0xFB // 3F: 251 'û' "Latin small letter U with circumflex" 0xFB // 3F: 251 'û' "Latin small letter U with circumflex"
}; };
public boolean canParse(String mimeType) { private final BitArray seiBuffer;
private final StringBuilder stringBuilder;
/* package */ Eia608Parser() {
seiBuffer = new BitArray();
stringBuilder = new StringBuilder();
}
/* package */ boolean canParse(String mimeType) {
return mimeType.equals(MimeTypes.APPLICATION_EIA608); return mimeType.equals(MimeTypes.APPLICATION_EIA608);
} }
public List<ClosedCaption> parse(byte[] data, int size, long timeUs) { /* package */ void parse(byte[] data, int size, long timeUs, List<ClosedCaption> out) {
if (size <= 0) { if (size <= 0) {
return null; return;
} }
BitArray seiBuffer = new BitArray(data, size);
stringBuilder.setLength(0);
seiBuffer.reset(data, size);
seiBuffer.skipBits(3); // reserved + process_cc_data_flag + zero_bit seiBuffer.skipBits(3); // reserved + process_cc_data_flag + zero_bit
int ccCount = seiBuffer.readBits(5); int ccCount = seiBuffer.readBits(5);
seiBuffer.skipBytes(1); seiBuffer.skipBytes(1);
List<ClosedCaption> captions = new ArrayList<ClosedCaption>();
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < ccCount; i++) { for (int i = 0; i < ccCount; i++) {
seiBuffer.skipBits(5); // one_bit + reserved seiBuffer.skipBits(5); // one_bit + reserved
boolean ccValid = seiBuffer.readBit(); boolean ccValid = seiBuffer.readBit();
...@@ -129,12 +134,10 @@ public class Eia608Parser { ...@@ -129,12 +134,10 @@ public class Eia608Parser {
// Control character. // Control character.
if (ccData1 < 0x20) { if (ccData1 < 0x20) {
if (stringBuilder.length() > 0) { if (stringBuilder.length() > 0) {
captions.add(new ClosedCaption(ClosedCaption.TYPE_TEXT, stringBuilder.toString(), out.add(new ClosedCaptionText(stringBuilder.toString(), timeUs));
timeUs));
stringBuilder.setLength(0); stringBuilder.setLength(0);
} }
captions.add(new ClosedCaption(ClosedCaption.TYPE_CTRL, out.add(new ClosedCaptionCtrl(ccData1, ccData2, timeUs));
new String(new char[] {(char) ccData1, (char) ccData2}), timeUs));
continue; continue;
} }
...@@ -146,10 +149,8 @@ public class Eia608Parser { ...@@ -146,10 +149,8 @@ public class Eia608Parser {
} }
if (stringBuilder.length() > 0) { if (stringBuilder.length() > 0) {
captions.add(new ClosedCaption(ClosedCaption.TYPE_TEXT, stringBuilder.toString(), timeUs)); out.add(new ClosedCaptionText(stringBuilder.toString(), timeUs));
} }
return Collections.unmodifiableList(captions);
} }
private static char getChar(byte ccData) { private static char getChar(byte ccData) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment