Начнем немного с предыстории. Не так давно у нас появился проект, android tv, в котором одна из фич была воспроизведение сразу несколько видео одновременно, то есть юзер смотрит на экран и видит 4 видео. Потом выбирает одно из них и смотрит его уже в фул скрине. Задача ясна, осталось только сделать. Особенность в том, что видео приходит в формате HLS. Я думаю, что если вы читаете это, то уже знакомы с HLS, но все же вкратце — нам дается файл, в котом есть ссылки на несколько потоков, которые должны меняться в зависимости от текущей скорости интернета.
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=688301
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0640_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=165135
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0150_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=262346
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0240_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=481677
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/0440_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1308077
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/1240_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1927853
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/1840_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=2650941
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/2540_vod.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=3477293
http://qthttp.apple.com.edgesuite.net/1010qwoeiuryfg/3340_vod.m3u8
Первым же делом мы начали реализовывать даную фичу черер EXOPlayer. Что было достаточно логично, так как EXOPlayer использует аппаратные кодеки для воспроизведения видео потока. Но оказалось, что у EXO есть своя темная сторона. Когда с помощью EXO запускается больше, чем один поток, никто не знает, что произойдет. В нашем случае, когда мы запускали 4 потока, на некоторых девайсах все работало хорошо, на некоторых работало только 3, а четвертый не запускался, а на некоторых, например, на Nexus 7 2013 происходило кое-что другое. Когда Nexus 72013 запускал больше 1 потока, аппаратные кодеки просто падали и ни одно видео не работало, не только в нашем приложении, но и в других приложениях, которые используют аппаратные кодеки. Единственный способ поднять их — это перезагрузить девайс. Как оказалось, этой задаче была посвящена тема на гитхабе. Как стало ясно, использовать аппаратные кодеки мы не можем, значит, нужно использовать программные кодеки и я напомню, что основная задача была играть 4 видео одновременно.
И начался велики поиск и искали мы долго и пробовали мы многое, но единственное, что нас устроило, было IJKPlayer. Это плеер, который является оберткой ffmpeg. Он воспроизводил HLS, играл их в 4 потока, а так же воспроизводил другие потоки, которые EXOplayer играл не на всех девайсах (например, HEVC). И очень долго все было хорошо, пока мы не начали замечать, что плеер всегда играет один и тот же поток и не меняет его в зависимости от пропускной способности сети. Для маленьких видео привью это не было проблемой, а вот для фул скрина это была проблема.
Поискав, оказалось, что потоки не меняются, а сам хозяин IJKPplayer посоветовал парсить потоки отдельно от плеера и запускать именно тот, что нужен (так же тикет с ffmpeg). Естественно, это не подходило потому что плеер должен сам подстраиваться относительно интернета. Проблема проблемой, а решать ее надо. В интернете ничего не получилось найти так, что было принято решение самолично добавить в либу логику по смене потоков. Но перед, тем как что-то делать, надо понять, где это делать. Сам FFMPEG является очень большой либой и не так просто понять, что есть что, но я выделил для вас несколько основных мест, с которыми нам нужно будет работать.
Итак, основные моменты, которые нам нужно знать:
- Есть метод read_data, который находится в libavformat/hls.c, здесь происходит основная магия. Здесь мы скачиваем поток и кладем его в буфер. А в конце метода есть goto restart, где и происходит смена сегмента. Перед этим рестартом мы и будем заменять поток, если это будет нам нужно.
- Второй объект, который нас интересует — это libavformat/avio.c. Здесь есть метод ffurl_close, который вызывается когда ссылка закрывается, а значит, здесь мы будем подытоживать текущую пропускную способность. А так же метод ffurl_open, который, конечно же, открывает наш поток, а значит здесь мы будем обнулять счетчик загруженных данных, а так же перезапускать таймер.
- Так же будет неплохо обратить ваше внимание на методы new_variant и new_playlist — в них создается плейлист со всех возможных битрейтов. По моим наблюдениям плеер берет первый айтем из списка и играет его, если произошла какая-то ошибка, то он берет второй айтем. Если вам необходимо сделать так, чтобы игрался только самый маленький (что логично, если воспроизводить 4 потока одновременно) или самый большой поток, то обратите внимание на эти методы.
Итак, подытожим наши задачи:
- Вычислить текущую пропускную способность
- Подменить ссылку, если это необходимо, для соответственной пропускной способности
- Почистить данные после того, как юзер перестанет смотреть видео
Листинг:
#include <stdint.h>
#ifndef IJKPLAYER_TEST_H
#define IJKPLAYER_TEST_H
extern int64_t start_loading;
extern int64_t end_loading ;
extern int64_t loaded_bytes;
extern int64_t currentBitrate;
extern int64_t diff;
//массив ссылков
extern char** urls;
//массив пропускных способнойстей, соответствующий массиву ссылок выше
extern int64_t* bandwidth;
extern int n_arrays_items;
extern char* selected_url;
extern int current_url_index;
extern int64_t current_bandwidth;
void saveStartLoadingData();
int64_t getStartLoading();
//проверяем инициализирован ли менеджер
int isInited();
//добавляем к счетчику скачаных байтов, количество скачаных байт за один раз
void addToLoadingByte(int64_t bytesCount);
//конец загрузки данного сегмента, считаем время затраченое на текущую операцию загрузки сегмента
void endOfLoading();
//высчитываем текущий битрейт
void calculateAndSaveCurrentBitrate();
int64_t getDiff();
int64_t getLoadedBites();
int64_t getEndLoading();
int64_t getCurrentBitrate();
void setFullUrl(char* url);
void setParturlParts();
//Есть ли у нас вообще битрейты
int doWeHaveBadwidth();
//создаем массив ссылок
void createDataArrays(int n_items);
//заполняем массив ссылок
void addData(int i, char* url, int64_t band_width);
//освобождаем память
void freeData();
//возвращаем текущую выбранную ссылку
char* getCurrentUrl();
//сравниваем ссылку с текущей выбранной ссылкой
int compareUrl(char* url);
//находим поток подходящий под тукущую пропускную способность
void findBestSolutionForCurrentBandwidth();
char* getUrlString(int index);
#endif //IJKPLAYER_TEST_H
#include "bitrate_manager.h"
#include <time.h>
#include <stdint.h>
#include <string.h>
#include "libavutil/log.h"
static const int64_t ONE_SECOND= 1000000000LL;
int64_t start_loading;
int64_t end_loading ;
int64_t loaded_bytes;
int64_t currentBitrate;
int64_t diff;
char** urls;
int64_t* bandwidth;
int n_arrays_items;
char* selected_url;
int current_url_index;
int64_t current_bandwidth;
/*
* It conyains current last index + 1
*/
int pointerAfterLastItem;
int isInitedData = 0;
int64_t now_ms() {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t) now.tv_sec*1000000000LL + now.tv_nsec;
}
void saveStartLoadingData(){
loaded_bytes = 0LL;
start_loading = now_ms();
}
int64_t getStartLoading(){
return start_loading;
}
int isInited(){
return isInitedData;
}
void addToLoadingByte(int64_t bytesCount){
loaded_bytes += bytesCount;
}
void endOfLoading(){
end_loading = now_ms();
diff = end_loading - start_loading;
}
void calculateAndSaveCurrentBitrate(){
if(loaded_bytes != 0) {
currentBitrate = loaded_bytes * ONE_SECOND / diff;
}
loaded_bytes = 0;
}
int64_t getDiff(){
return diff;
}
int64_t getLoadedBites(){
return loaded_bytes;
}
int64_t getEndLoading(){
return end_loading;
}
int64_t getCurrentBitrate(){
return currentBitrate;
}
int doWeHaveBadwidth(){
if(bandwidth && pointerAfterLastItem != 0){
return 1;
}
return 0;
}
void createDataArrays(int n_items){
isInitedData = 1;
pointerAfterLastItem = 0;
n_arrays_items = n_items;
bandwidth = (int64_t*) malloc(n_items * sizeof(int64_t));
urls = (char**) malloc(n_items * sizeof(char*));
for(int i =0; i < n_items; i++){
urls[i] = (char*) malloc(sizeof(char));
}
}
void addData(int i, char* url, int64_t band_width){
if(band_width == 0LL){
return;
}
free(urls[i]);
urls[i] = (char*) malloc(strlen(url) * sizeof(char));
strcpy(urls[pointerAfterLastItem], url);
bandwidth[pointerAfterLastItem] = band_width;
pointerAfterLastItem++;
}
void freeData(){
if(isInitedData == 0){
return;
}
isInitedData = 0;
for(int i = 0;i < pointerAfterLastItem;++i) free(urls[i]);
free(urls);
free(bandwidth);
}
char* getCurrentUrl(){
return selected_url;
}
int compareUrl(char* url){
if(selected_url){
return strcmp(selected_url, url);
}
return 0;
}
void findBestSolutionForCurrentBandwidth() {
if (currentBitrate == 0) {
selected_url = urls[0];
current_url_index = 0;
current_bandwidth = bandwidth[0];
return;
}
if (currentBitrate == current_bandwidth) return;
int index = 0;
int64_t selectedBitrate = bandwidth[index];
int start = 0;
int length = pointerAfterLastItem;
for (int i = start; i < length; i++) {
if (currentBitrate >= bandwidth[i]
&& selectedBitrate <= bandwidth[i]) {
index = i;
selectedBitrate = bandwidth[i];
}
}
if (current_bandwidth != selectedBitrate) {
selected_url = urls[index];
current_url_index = index;
current_bandwidth = selectedBitrate;
}
}
Теперь переходим к листингу самого ffmpeg
В avio.c добавляем
int ffurl_open(URLContext **puc, const char *filename, int flags,
const AVIOInterruptCB *int_cb, AVDictionary **options)
{
if(isInited() == 1) {
saveStartLoadingData();
}
….
}
….
int ffurl_close(URLContext *h)
{
if( isInited() == 1) {
endOfLoading();
calculateAndSaveCurrentBitrate();
}
return ffurl_closep(&h);
}
В hls.c метод read_data будет выглядеть так
static int read_data(void *opaque, uint8_t *buf, int buf_size)
{
struct playlist *v = opaque;
HLSContext *c = v->parent->priv_data;
// инициализируем плейлист
if (isInited() == 0) {
createDataArrays(c->n_variants);
for (int i = 0; i < c->n_variants; i++) {
addData(i, c->playlists[i]->url, c->variants[i]->bandwidth);
}
}
//при необходимости, подменяем ссылки
if(doWeHaveBadwidth() == 1 && isInited() == 1 && compareUrl(v->url) != 0){
strcpy(v->url, getCurrentUrl());
}
int ret, i;
int just_opened = 0;
restart:
if (!v->needed)
return AVERROR_EOF;
if (!v->input) {
int64_t reload_interval;
/* Check that the playlist is still needed before opening a new
* segment. */
if (v->ctx && v->ctx->nb_streams &&
v->parent->nb_streams >= v->stream_offset + v->ctx->nb_streams) {
v->needed = 0;
for (i = v->stream_offset; i < v->stream_offset + v->ctx->nb_streams;
i++) {
if (v->parent->streams[i]->discard < AVDISCARD_ALL)
v->needed = 1;
}
}
if (!v->needed) {
av_log(v->parent, AV_LOG_INFO, "No longer receiving playlist %d\n",
v->index);
return AVERROR_EOF;
}
/* If this is a live stream and the reload interval has elapsed since
* the last playlist reload, reload the playlists now. */
reload_interval = default_reload_interval(v);
reload:
if (!v->finished &&
av_gettime_relative() - v->last_load_time >= reload_interval) {
if ((ret = parse_playlist(c, v->url, v, NULL)) < 0) {
av_log(v->parent, AV_LOG_WARNING, "Failed to reload playlist %d\n",
v->index);
return ret;
}
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
/* If we need to reload the playlist again below (if
* there's still no more segments), switch to a reload
* interval of half the target duration. */
reload_interval = v->target_duration / 2;
}
if (v->cur_seq_no < v->start_seq_no
|| v->cur_seq_no > (v->start_seq_no + (v->n_segments * 5)) ) {
av_log(NULL, AV_LOG_WARNING,
"skipping %d segments ahead, expired from playlists\n",
v->start_seq_no - v->cur_seq_no);
v->cur_seq_no = v->start_seq_no;
}
if (v->cur_seq_no >= v->start_seq_no + v->n_segments) {
if (v->finished)
return AVERROR_EOF;
while (av_gettime_relative() - v->last_load_time < reload_interval) {
if (ff_check_interrupt(c->interrupt_callback))
return AVERROR_EXIT;
av_usleep(100*1000);
}
/* Enough time has elapsed since the last reload */
goto reload;
}
ret = open_input(c, v);
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
if (ret < 0) {
if (ff_check_interrupt(c->interrupt_callback))
return AVERROR_EXIT;
av_log(v->parent, AV_LOG_WARNING, "Failed to open segment of playlist %d\n",
v->index);
v->cur_seq_no += 1;
goto reload;
}
just_opened = 1;
}
ret = read_from_url(v, buf, buf_size, READ_NORMAL);
//добавляем количество загруженных байт в счетчик
if(isInited() == 1 && doWeHaveBadwidth() == 1) {
addToLoadingByte(ret);
}
if (ret > 0) {
if (just_opened && v->is_id3_timestamped != 0) {
/* Intercept ID3 tags here, elementary audio streams are required
* to convey timestamps using them in the beginning of each segment. */
intercept_id3(v, buf, buf_size, &ret);
}
return ret;
}
ffurl_close(v->input);
v->input = NULL;
v->cur_seq_no++;
c->cur_seq_no = v->cur_seq_no;
// загрузка была завершена. Ищем подходящюю ссылку для текущего bandwidth если она отличается то заменяем страую ссылку на новую
if(isInited() == 1
&& doWeHaveBadwidth() == 1) {
findBestSolutionForCurrentBandwidth();
if (compareUrl(v->url) != 0) {
strcpy(v->url, getCurrentUrl());
}
}
goto restart;
}
Остались мелочи добавляем новые файлы в makefile внутри libavformat в HEADERS и OBJS добвляем соответсвтующие упоминания
NAME = avformat
HEADERS = avformat.h avio.h version.h avc.h url.h internal.h bitrate_mamnger.h
OBJS = allformats.o avio.o aviobuf.o cutils.o dump.o format.o id3v1.o id3v2.o metadata.o mux.o options.o os_support.o riff.o sdp.o url.o utils.o avc.o bitrate_mamnger.o
Также добавляем метод IjkMediaPlayer_freeBitateWorkData в ijkplayer_jni.c, который будем вызывать после завершения просмотра, что бы очистить данные.
static void
IjkMediaPlayer_freeBitateWorkData(JNIEnv *env, jclass clazz){
freeData();
}
//и добавляем данный метод в массив g_methods
...
{ "_freeBitateWorkData", "()V", (void *)IjkMediaPlayer_freeBitateWorkData },
...
Все, наша реализация готова, теперь остается пересобрать и смотреть видео с меняющимися потокоми.
Комментарии (24)
atemik
06.08.2016 23:01+1Я что-то не понимаю, или функция compareUrl — это просто нет слов и косяк на косяке?
zo2m4bie
06.08.2016 23:03+2Возможно. Если вы укажите на ошибки я их исправлю. В этом методе я сначало сравнил размера, а после сравнил url посимвольно сравнил ссылки.
Godless
06.08.2016 23:41я не знаю что имел ввиду автор выше, но как минимум нужно использовать безопасные функции, вроде strlen_s и не забывать проверять указатель перед использованием…
atemik
07.08.2016 00:33+2Изобретаем велосипед. Чем просто стандартная strcmp не устроила?
Мы два раза бежим по обоим строкам (сначала strlen, потом в цикле), что в общем-то лишняя работа, достаточно одного прохода. Зачем вообще размер сравнивать, если один фиг в ноль упрёмся в более короткой строке?zo2m4bie
07.08.2016 13:19+1Согласен с вами. Почему-то во время разработки я ушол от метода strcmp, сейчас вернул его и исправил с статье. Но метод отсавил, что бы не менять везде
Shtucer
08.08.2016 11:56Как один из косяков: URL в общем случае не просто строка. Для уверенности я бы урлы ещё бы и декодировал бы.
zo2m4bie
08.08.2016 12:01+1Данный момент я оставил на совести самого ffmpeg. Здесь я уже работаю с теми ссылками которые он сам сделал и с которыми она сам может работать
Netmoose
09.08.2016 09:26-1реализывать — реализовывать
ни одно видео нb — ни одно видео не
не только в нашем приложение — не только в нашем приложении
а и в других приложениях — но и в других приложениях
И начался велики пойиск — И начался великий поиск
является оберткой ffmep — является оберткой ffmpeg ???
кладем его в буффер — кладем его в буфер
смена сигмента — смена сегмента
Так же будет не плохо — Так же будет неплохо
сделать так, что бы — сделать так, чтобы
Итак, подитожим — Итак, подытожим
для ссответственной пропускной способности — для соответственной пропускной способности
Может стоит вычитывать перед отправкой?
monah_tuk
14.08.2016 16:42+1Ваше решение несёт практическую пользу? Если да, то почему бы не оформить ваш код согласно соглашению по кодированию FFmpeg и не отправить патч? По всему опыту: нужные вещи принимают хорошо. Плюс на ревью бы указали на косяки: вдруг что-то не так понято в кишках FFmpeg.
К примеру,
now_ms()
, зачем, если естьlibavutil/time.h
иav_gettime()
, которая, правда, выдаёт время в микросекундах, либоav_gettime_relative()
которая более соответствует вашим потребностям (с тем же увеличением точности).
Или, зачем такое количество глобальных переменных в
bitrate_manager.c
? Почему не добавитьstatic
к ним, ведь доступ только из функций внутри.
Или, внутри FFmpeg для выделения памяти непринято использовать
malloc()
напрямую. Воспользуйтесьav_malloc()
илиav_mallocz()
(если нужно сразу занулить память). Парная функция для освобождения —av_free()/av_freep()
— вторая сразу зануляет указатель, дабы избежать. Для случая массивов же, вообще следует воспользоватьсяav_malloc_array()/av_realloc_array()
Или, подобное:
urls[i] = (char*) malloc(strlen(url) * sizeof(char)); strcpy(urls[pointerAfterLastItem], url);
вообще стоит заменить одним вызовом
av_strdup()
Ну и последнее, почему вы как-то отдельно от всего остального вызываете
freeData()
? Почему бы не поместить его вhls_close()
? Ведь логично, что такой механизм может потребоваться при чтении этого формата, логично, что освобождать нужно при закрытии оного.
Ещё бы я пересмотрел бы правки в
avio.c
и локализовал бы их только вhls.c
. Дабы не превращать код в лапшу.
После такой краткой ревизии: решение в таком виде я бы постеснялся кому-то показывать, а тем более использовать, кроме как для случая — "быстро проверить".
zo2m4bie
15.08.2016 14:53Практическую пользу несет. Тоесть, с данной проблемой сталкиваются многие и решений в интернете я не нашол. Откровенно говоря, я не видел «согласно соглашению по кодированию FFmpeg», но патч я и не хотел создавать вместо этого я оформил в виде руководства и добавил к тикету. С вашими замечаниями согласен, здесь сказалось неуверенное знание FFmpeg(это, кстати, основная причина, почему я не хотел делать патч, так как в коде могут быть проблемы, которые я не знаю ). Переработаю код согласно вашим замечаниям и опубликую изменения. Спасибо за ревью
monah_tuk
16.08.2016 00:51+1я не видел «согласно соглашению по кодированию FFmpeg»
http://ffmpeg.org/developer.html#Coding-Rules-1
Практическую пользу несет
но патч я и не хотел создавать вместо этого я оформил в виде руководства и добавил к тикетуПатч добавляет +100500 к шансу попасть в апстрим.
Bo_bda
14.08.2016 16:47+1я так понимаю что вы оставили либу в девстенном виде и только заменили часть файлов? делали ли пул реквест на репо?
zo2m4bie
15.08.2016 14:39Да, либу я не менял, и старался как можно меньше изменений делать в ней, что бы избежать какх либо ошибок. Пул реквест не делал, так как работал в рамке ijkplayer'a. Оформил чтото типо руководства в англ виде и сделал коментарий под тикетом, что бы если комуто необходимо мог воспользоваться
agent10
Читал, что в Андроид N будет фича — Picture-in-Picture
Вот интересно, можно ли будет играть 2 потока видео в этом случае, т.е. один на основном экране одного приложение, а другой маленький от какого-нибудь другого приложения.
Если да, то возможно вашу проблему уже решили в Android N…
zo2m4bie
Я так понял, что это фишка именно для сворачивания видео и работы с остальным приложением пока видео продолжает играть
In Android N, Android TV users can now watch a video in a pinned window in a corner of the screen when navigating within apps.
Судя по данной записи здесь идет разговор о том, что отображается только одно видео в режиме PIP
PIP is intended for activities that play full-screen video. When switching your activity into PIP mode, avoid showing anything except video content. Track when your activity enters PIP mode and hide UI elements, as described in Handling UI During Picture-in-picture.
vikarti
а собственно зачем для реализации аналога Picture-in-Picture нужна специальная поддержка? берем permission android.permission.SYSTEM_ALERT_WINDOW и работаем. Так умеет например BSPlayer.
отличие в том что готовая поддержка в библиотеках и permission не надо?
zo2m4bie
Попробовал BSPlayer, не получилось у меня включить оконный режим. Но судя по данному виде здесь как вы и сказали есть интерфейс что бы понять что мы в оконном режиме и работаем без пермишенов. Так же приложение с PIP по другому отображается в истории приложений
vikarti
пробую по описанию фичи действовать — «background playback in popup window (long tap on button Back to playback video and audio in popup video)» и видео стало поверх плеера, затем Home и видео есть поверх рабочего стола.
в истории разумеется отображается как обычно (и перекрывает ее)