Опусы Программиста

Небольшие заметки о Java и libGDX

Пишем свой видеоплеер, использование связки MediaCodec, MediaExtractor, SurfaceView

Однажды передо мной встала задача интеграции видеконтента в приложение. Однако нужно было добиться специфичного поведения, чего стандартный 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();}
   }

и будем лицезреть нашего Кота Максвелла.

Ссылки на офф. документацию. Почитайте, там куча методов, чтобы можно было делать тонкую настройку:

  1. Android MediaCodec
  2. 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) {}
    };
}

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *