Однажды передо мной встала задача интеграции видеконтента в приложение. Однако нужно было добиться специфичного поведения, чего стандартный VideoView не позволял сделать. Задачей было именно влиять на процесс воспроизведения.
Немного оффтопа
Сейчас, как принято среди труЪ-программистов, на все уже есть готовые библиотеки для любых задач. Вот и сама Google создала сопровождающую библиотеку AndroidX Media (ранее это был ExoPlayer), только вот разбираться с ней у меня не было ни времени, ни желания. Ибо очередной шедевр, упрощающий разработку тянул за собой почти всю библиотеку совместимости AndroidX Jetpack, в результате чего приложение разбухло, а нужного поведения я так и не добился. Поэтому изобретаем свой велосипед.
Гости сегодняшней программы — знакомимся
MediaCodec — это внутренний класс Android OS и не требует внешних зависимостей. Его основное назначение — декодировать/кодировать поступающий входной поток данных, и выводить его на выходной приемник. Однако сам он ничего не умеет, кроме как заниматься перекодировкой медиаконтента, поэтому ему нужно сообщить какой формат данных и кто поставщик этих данных, а также куда будут выводиться эти данные.
MediaExtractor — это внутренний класс, отвечает как раз таки за чтение входного файла, навигацию по данным, и предоставляет информацию о читаемом файле. Короче говоря, является тем чуваком для MediaCodec который и будет ему говорить, что делать.
SurfaceView — классический и знакомый всем класс, отвечающий за рисовку на поверхностях, в обход стандартного набора Views. При разработке ПО вы должны были с ним познакомиться.
Итог: для нашего проекта MediaExtractor будет выгружать данные из файла и отправлять их MediaCodec’у, который, раскодировав, будет их отрисовывать на нашем SurfaceView.
Итак, создадим простой проект в Android Studio. Пока имеем пустышку MainActivity со стандартным содержанием:
package ru.manku.mediacodectest;
import android.app.Activity;
import android.os.Bundle;
public class MainActivity extends Activity {
@Override public void onCreate(Bundle savedInstance) {
super.onCreate(savedInstance);
setContentView(R.layout.main_layout);
}
}
Давайте сделаем так, чтобы мы могли просто показать видеоконтент. Напишем в файле /res/layout/main_layout.xml следующую разметку:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:background="@color/black"
android:layout_height="match_parent">
<SurfaceView
android:layout_centerInParent="true"
android:id="@+id/video"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</RelativeLayout>
В папку res/raw/ положим любое видео, нужной нам длительности в формате mp4 (технически — подойдет любой формат, поддерживаемый в supported formats вашей минимальной версии SDK). В методе onCreate свяжем все наши Views по id с их программными объектами:
...
SurfaceView video;
@Override public void onCreate(Bundle savedInstance) {
super.onCreate(savedInstance);
setContentView(R.layout.main_layout);
video = findViewById(R.id.video);
...
Итак, мы подготовили обвязку для нашего видеоплеера. Создадим метод подготовки плеера, который выгружает в папку кэша нашего приложения видеофайл (я назвал его maxwell_cat) в случае, если копия не создана. Далее классическая процедура — создаем экземпляр MediaExtractor и подсовываем ему абсолютный путь до нашего видеофайла через метод setDataSource(). Дальше нам необходимо распаковать информацию о видеодорожке, её формате и т.д. Этому посвящен цикл for() (только без звука — один комплект MediaCodec & MediaExtractor — одна возможная дорожка — будь то аудио, или видео, однако ничего не мешает вашей бурной фантазии одновременно использовать несколько комлектов Codec и Extractor).
Дальше на основе собранной информации собираем экземпляр MediaCodec, говорим ему, чтобы он использовал формат такой-то (mf), выводил изображение на поверхность такую-то (video.getHolder().getSurface()), а сам работал в режиме декодера (int: 0). В итоге код метода такой:
private MediaExtractor mex;
private MediaCodec mc;
private MediaFormat mf;
public long PLAY_TIME, frametime = 0; //об этом поговорим чуть позже
public void prepareMedia() throws IOException {
File fvideo = new File(getCacheDir(), "video.mp4");
if(!fvideo.exists()) {
InputStream is = getResources().openRawResource(R.raw.maxwell_cat);
OutputStream os = null;
try {
os = new FileOutputStream(fvideo);
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
} finally {
is.close();
os.close();
}
}
mex = new MediaExtractor();
mex.setDataSource(fvideo.getAbsolutePath());
mf = null;
String MIME_TYPE = null;
int tracks = mex.getTrackCount();
for(int i=0; i<tracks; i++) {
//Ищем первую попавшуюся видеодорожку и говорим MediaExtractor,
//что мы будем использовать её
mf = mex.getTrackFormat(i);
MIME_TYPE = mf.getString(MediaFormat.KEY_MIME);
if(MIME_TYPE.startsWith("video/")) {
mex.selectTrack(i);
break;
}
}
mc = MediaCodec.createDecoderByType(MIME_TYPE);
}
Далее перед нами стоит важный выбор — или вы будете сами создавать отдельный поток, отвечающий за получение пакетов и их отрисовку, или сказать системе, чтобы она сама занималась этим. Последний вариант появился в Android 5.0 (API 21) и выше, до этого, вам приходилось бы самим всем заниматься. Настоятельно рекомендую, чтобы система сама отвечала за распаковку, так как будут выделены native-ресурсы, что благоприятно скажется на производительности.
Было решено использовать вариант, где меньше головной боли. Для этого необходимо создать вспомогательный объект с таким вот содержанием после или перед нашим методом prepareMedia():
public void prepareMedia() throws IOException {
...
mc.setCallback(callback);
}
// Создаем каллбэк, чтобы передать его кодеру
private final MediaCodec.Callback callback = new MediaCodec.Callback() {
@Override public void onInputBufferAvailable(MediaCodec codec, int index) {
}
@Override public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {
}
@Override public void onError(MediaCodec codec, MediaCodec.CodecException e) {}
@Override public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {}
};
Первые два метода нашего коллбэка, отвечают за чтение пакета данных (сэмпла) и его отрисовку. В них и происходит вся магия отрисовки и контроля над воспроизведением. Следующий код в onInputBufferAvailable() вызывается, когда система готова к распаковке пакета данных.
...
@Override public void onInputBufferAvailable(MediaCodec codec, int index) {
ByteBuffer iBuff = mc.getInputBuffer(index);
int samples = mex.readSampleData(iBuff, 0);
if(samples>=0) {
frametime = (mex.getSampleTime()/1000)+PLAY_TIME;
mc.queueInputBuffer(index, 0, samples, mex.getSampleTime(), 0);
mex.advance(); // advance() - говорит MediaExtractor распаковать следующий фрейм/сэмпл
}else {
//семплов больше нет - говорим декодеру, что поток закончился
mc.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
}
}
...
Если число сэмплов (видеофреймов) больше нуля или равно ему, то говорим кодеку, чтобы он попросил эти сэмплы у MediaExtractor (неявный вызов спрятанный в queueInputBuffer()). Если же семплов нет, то отсылаем событие END_OF_STREAM… В этой части кода, вы можете что угодно делать — синхронизировать время (как например я, сохраняя данные о текущем воспроизведении в переменную frametime), получать пройденный промежуток времени, прыгать по шкале воспроизведения, пауза, повтор и т.д. Короче, полный контроль, за тем, что происходит.
За отрисовку на наш SurfaceView отвечает метод onOutputBufferAvailable(). Примерное содержание, которое просто берет декодированный пакет стандартным образом и рисует, и может быть таким:
@Override public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {
try{
long SYNC = frametime-System.currentTimeMillis();
if(SYNC>2) {
Thread.sleep(SYNC/2);
mc.releaseOutputBuffer(index, true);
Thread.sleep(SYNC/2);
}else mc.releaseOutputBuffer(index, true);
}catch(Exception e) {}
}
ВАЖНО: Официальная документация Google MediaCodec пишет, что достаточно вызова mc.releaseOutputBuffer(index, true). Это говорит системе, высвободить фрейм и отрисовать его на SurfaceView со стандартной задержкой (речь идет о FPS видеодорожки). Однако, начиная с Android 8 появился баг на слишком высокую частоту кадров, в результате чего видео в 24/30 кадра в секунду воспроизводится в 2-3 раза быстрее. Есть перегруженный метод releaseOutputBuffer(index, timestampNanosec), но он работает с наносеками и система его проигнорирует, если оно будет слишком большим. Короче, знатно попотели, но как у меня молотило видео в 2 раза быстрее, так и молотит дальше, игнорируя мои душевные пожелания.
Очень неприятная вещь, которую можно фиксить классическим Thread.sleep(). Так как система выполняет наш каллбэк в отдельном потоке — это никак не скажется на нашем основном потоке приложения, однако мы добьемся синхронизации. Для этого как раз таки мы сохраняли начало времени воспроизведения, для каждого фрейма — его время воспроизведения и уже на отрисовке по разнице между ожидаемым и текущем времени засыпали на половину разницы (ну или если она меньше 2 миллисекунд — сразу рисовали), потом опять засыпали. Это убирает «дерганность» воспроизведения и делает немного плавнее.
Чтобы не удерживать ресурсы, необходимо по окончании просмотра уничтожить экземпляры MediaExtractor и MediaCodec. Так как наш проект тестовый, мы может уничтожать прямо в onDestroy() нашей активности, однако для сложных приложений, это нужно делать раньше — сразу после того, как видео станет ненужным.
@Override public void onDestroy() {
mf = null;
if(mc!=null) {
mc.stop(); mc.release();
}
if(mex!=null) mex.release();
super.onDestroy();
}
При запуске приложения SurfaceView еще не готов, поэтому мы не можем мгновенно запустить воспроизведение. Создадим еще один каллбэк, но уже для SurfaceView, когда система его подготовит и он будет готов отрисовывать данные, то выполнит метод surfaceCreated() с нашим кодом запуска и конфигурации воспроизведения:
private final SurfaceHolder.Callback holder = new SurfaceHolder.Callback() {
@Override public void surfaceCreated(SurfaceHolder holder) {
mc.configure(mf, video.getHolder().getSurface(), null, 0);
mc.start();
PLAY_TIME = System.currentTimeMillis();
}
@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override public void surfaceDestroyed(SurfaceHolder holder) {}
};
Теперь осталось все это запустить в единое целое. Для этого в onCreate() просто добавим следующий код, который связывает поведение SurfaceView со стартом плеера:
@Override public void onCreate(Bundle savedInstance) {
...
video.getHolder().addCallback(holder);
try {
prepareMedia();
}catch(Exception e) { e.printStackTrace();}
}
и будем лицезреть нашего Кота Максвелла.
Ссылки на офф. документацию. Почитайте, там куча методов, чтобы можно было делать тонкую настройку:
- Android MediaCodec
- Android MediaExtractor
Полный SourceCode MainActivity — нашей активити, вдруг вы не поняли, где и что писалось.
package ru.manku.mediacodectest;
import android.app.Activity;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.os.Bundle;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.widget.Button;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
public class MainActivity extends Activity {
Button play, stop, top5, back5;
SurfaceView video;
@Override public void onCreate(Bundle savedInstance) {
super.onCreate(savedInstance);
setContentView(R.layout.main_layout);
video = findViewById(R.id.video);
video.getHolder().addCallback(holder);
try {
prepareMedia();
}catch(Exception e) { e.printStackTrace();}
}
@Override public void onDestroy() {
mf = null;
if(mc!=null) {
mc.stop(); mc.release();
}
if(mex!=null) mex.release();
super.onDestroy();
}
private MediaExtractor mex;
private MediaCodec mc;
private MediaFormat mf;
public void prepareMedia() throws IOException {
File fvideo = new File(getCacheDir(), "video.mp4");
if(!fvideo.exists()) {
InputStream is = getResources().openRawResource(R.raw.maxwell_cat);
OutputStream os = null;
try {
os = new FileOutputStream(fvideo);
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
} finally {
is.close();
os.close();
}
}
mex = new MediaExtractor();
mex.setDataSource(fvideo.getAbsolutePath());
mf = null;
String MIME_TYPE = null;
int tracks = mex.getTrackCount();
for(int i=0; i<tracks; i++) { //Ищем первую попавшуюся видеодорожку и говорим MediaExtractor, что мы будем использовать её
mf = mex.getTrackFormat(i);
MIME_TYPE = mf.getString(MediaFormat.KEY_MIME);
if(MIME_TYPE.startsWith("video/")) {
mex.selectTrack(i);
break;
}
}
mc = MediaCodec.createDecoderByType(MIME_TYPE);
}
public long PLAY_TIME;
private final SurfaceHolder.Callback holder = new SurfaceHolder.Callback() {
@Override public void surfaceCreated(SurfaceHolder holder) {
mc.configure(mf, video.getHolder().getSurface(), null, 0);
mc.setCallback(callback);
mc.start();
PLAY_TIME = System.currentTimeMillis();
}
@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override public void surfaceDestroyed(SurfaceHolder holder) {}
};
private final MediaCodec.Callback callback = new MediaCodec.Callback() {
long frametime = 0;
@Override public void onInputBufferAvailable(MediaCodec codec, int index) {
ByteBuffer iBuff = mc.getInputBuffer(index);
int samples = mex.readSampleData(iBuff, 0);
if(samples>=0) {
frametime = (mex.getSampleTime()/1000)+PLAY_TIME;
mc.queueInputBuffer(index, 0, samples, mex.getSampleTime(), 0);
mex.advance(); // advance() - говорит MediaExtractor распаковать следующий фрейм/сэмпл
}else {
//семплов больше нет - говорим декодеру, что поток закончился
mc.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
}
}
@Override public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {
try{
long SYNC = frametime-System.currentTimeMillis();
if(SYNC>2) {
Thread.sleep(SYNC/2);
mc.releaseOutputBuffer(index, true);
Thread.sleep(SYNC/2);
}else mc.releaseOutputBuffer(index, true);
}catch(Exception e) {}
}
@Override public void onError(MediaCodec codec, MediaCodec.CodecException e) {}
@Override public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {}
};
}