从零开始学习音视频编程技术(十) FFMPEG Qt视频播放器之播放控制

原文地址:http://blog.yundiantech.com/?log=blog&id=13

到这里,我们的视频播放器已经可以同步播放音视频了。现在 是时候给他加上一些控制的功能了。如播放、暂停、跳转等。


一、暂停功能实现

    我们是在一个单独的线程中循环读取视频,所以只需要在需要暂停的时候,让读取的操作等下来即可。

做法如下:

1.首先引入一个变量用来记录是否处于暂停状态:

1
bool  isPause;   //暂停标志


2.读取线程根据此变量来判断是否读取。这样就实现了暂停功能了。 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
     while  (1)    
     {
         ...
 
         if  (is->isPause ==  true )
         {
             SDL_Delay(10);
             continue ;
         }
 
         if  (av_read_frame(pFormatCtx, packet) < 0)
         {
             break //这里认为视频读取完了
         }
         
         ...
     }


但是,我们总共是有3个线程在运行,分别是:

1)读取线程

2)解码视频线程

3)解码音频线程


因此,读取的线程虽然停下了,但由于队列中还有数据,解码视频和解码音频的线程可以继续获取到数据并显示和播放,这就导致了暂停操作有一定的延时。


解决方法:

在解码视频和解码音频的线程中也一并判断上面设的pause变量:


解码视频线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
while (1)
{
 
     if  (is->isPause ==  true //判断暂停
     {
         SDL_Delay(10);
         continue ;
     }
 
     if  (packet_queue_get(&is->videoq, packet, 1) <= 0)  break ; //队列里面没有数据了  读取完毕了
 
     ......
}


解码音频线程:

需要注意的是,音频线程有里面有2个循环的地方,因此需要在这2个地方都做判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
for  (;;) {
     while  (is->audio_pkt_size > 0) 
     {
 
         if  (is->isPause ==  true //判断暂停
         {
             SDL_Delay(10);
             continue ;
         }
 
         ....
 
         // We have data, return it and come back for more later
         return  resampled_data_size;
     }
 
     if  (is->isPause ==  true //判断暂停
     {
         SDL_Delay(10);
         continue ;
     }
 
     if  (pkt->data)
         av_free_packet(pkt);
     memset (pkt, 0,  sizeof (*pkt));
     
     ....
         
}

  

现在我们为VideoPlayer类加上2个函数,分别用来控制播放和暂停:

1
2
     bool  play();    
     bool  pause();


这两个函数中其实就是修改了下pause变量的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool  VideoPlayer::play()
{
     mVideoState.isPause =  false ;
 
     if  (mPlayerState != Pause)
     {
         return  false ;
     }
 
     mPlayerState = Playing;
 
     return  true ;
}
 
bool  VideoPlayer::pause()
{
     mVideoState.isPause =  true ;
 
     if  (mPlayerState != Playing)
     {
         return  false ;
     }
 
     mPlayerState = Pause;
 
     return  true ;
}



或许有人会问:为什么这里的pause被3个线程访问,都不加互斥锁呢?

因为这里仅仅是一个变量,很少出现其中一个线程写到一半,另一个线程就来访问的情况。


最主要的是,即使出现这种情况也没关系,举个例子:

假如,现在视频处于播放状态,pause是false,这时候主线程调用了暂停函数,将pause改成true,(由于一条赋值语句在汇编上是3条语句,因此中途CPU调度到另一个线程是很正常的),此时还没改完,就跑到了解码视频的线程,又刚好解码视频的线程处于判断这个变量的语句上,这时本该是暂停了,但由于主线程赋值语句没执行完,导致了pause还是false,于是解码线程就多解码了一张图片并显示了出来。


可以看出,这样没加互斥锁,仅仅导致了多显示一张图片,视频一秒钟都是显示十几张以上,我不相信有人可以感觉的出多显示这一张图片会有什么差别。

因此这个暂停操作在人眼看来还是“实时的”。


而且上面分析的还是非常极端的情况,就是2个线程刚好都处在访问这个变量的情况。


所以喽,这里没必要加互斥锁,我们的原则是能省事的地方尽量省事。



二、跳转功能实现

    和暂停一样,我们使用一个变量记录下是否需要跳转,在读取视频文件的线程中判断此变量,当需要跳转的时候就执行跳转操作。    

    跳转操作可以直接使用FFMPEG的跳转函数av_seek_frame来实现,函数原型如下:

1
int  av_seek_frame(AVFormatContext *s,  int  stream_index, int64_t timestamp, int  flags);

    

这里需要注意两点:

  1. 我们有2个队列,音频队列和视频队列,这2个队列里面的数据是可以播放几秒钟的,因此每次执行跳转的时候需要同时将队列清空,所以先添加一个清空队列的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static  void  packet_queue_flush(PacketQueue *q){
     AVPacketList *pkt, *pkt1;
 
     SDL_LockMutex(q->mutex);
     for (pkt = q->first_pkt; pkt != NULL; pkt = pkt1)
     {
         pkt1 = pkt->next;
         av_free_packet(&pkt->pkt);
         av_freep(&pkt);
     }
     q->last_pkt = NULL;
     q->first_pkt = NULL;
     q->nb_packets = 0;
     q->size = 0;
     SDL_UnlockMutex(q->mutex);
}


然后每次调用seek的时候便执行一次清空操作:

1
2
3
4
5
6
7
8
9
10
if  (av_seek_frame(is->ic, stream_index, 0,is->seek_flags) < 0) {     fprintf (stderr, "%s: error  while  seeking
",is->ic->filename);
else  {
     if  (is->audioStream >= 0) {
         packet_queue_flush(&is->audioq);
     }
     if  (is->videoStream >= 0) {
         packet_queue_flush(&is->videoq);
     }
}


这样执行之后,新的问题又来了。

每次跳转的时候都会出现花屏的现象。

是因为解码器中保留了上一帧视频的信息,而现在视频发生了跳转,从而使得解码器中保留的信息会对解码当前帧产生影响。

因此,清空队列的时候我们也要同时清空解码器的数据(包括音频解码器和视频解码器),

可以往队列中放入一个特殊的packet,当解码线程取到这个packet的时候,就执行清除解码器的数据:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if  (av_seek_frame(is->ic, stream_index, seek_target, AVSEEK_FLAG_BACKWARD) < 0) {     fprintf (stderr, "%s: error  while  seeking
",is->ic->filename);
else  {
     if  (is->audioStream >= 0) {
         AVPacket *packet = (AVPacket *)  malloc ( sizeof (AVPacket));  //分配一个packet 
         av_new_packet(packet, 10);
         strcpy (( char *)packet->data,FLUSH_DATA);
         packet_queue_flush(&is->audioq);  //清除队列
         packet_queue_put(&is->audioq, packet);  //往队列中存入用来清除的包
     }
     if  (is->videoStream >= 0) {
         AVPacket *packet = (AVPacket *)  malloc ( sizeof (AVPacket));  //分配一个packet 
         av_new_packet(packet, 10);
         strcpy (( char *)packet->data,FLUSH_DATA);
         packet_queue_flush(&is->videoq);  //清除队列
         packet_queue_put(&is->videoq, packet);  //往队列中存入用来清除的包
         is->video_clock = 0;
     }
}


然后在解码线程中如下操作(以解码图像线程为例,音频线程也是类似):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
while (1){
 
     if  (is->isPause ==  true //判断暂停
     {
         SDL_Delay(10);
         continue ;
     }
 
     if  (packet_queue_get(&is->videoq, packet, 1) <= 0)
     {
         break ; //队列里面没有数据了  读取完毕了
     }
 
     //收到这个数据 说明刚刚执行过跳转 现在需要把解码器的数据 清除一下        
     if ( strcmp (( char *)packet->data,FLUSH_DATA) == 0)
     {
         avcodec_flush_buffers(is->video_st->codec);
         av_free_packet(packet);
         continue ;
     }
     
     ....
}


好了,现在我们给VideoPlayer类也封装一个seek函数吧:

1
void  seek(int64_t pos);  //单位是微秒

和暂停函数类似,seek函数也只是设置一下变量而已:

1
2
3
4
5
6
7
void  VideoPlayer::seek(int64_t pos){
     if (!mVideoState.seek_req)
     {
         mVideoState.seek_pos = pos;
         mVideoState.seek_req = 1;
     }
}



到这里跳转还有一个问题没有解决:

第一篇文章我们就说过视频是每隔几秒才出现一个关键帧,而av_seek_frame只能跳转到关键帧,因此这里就会存在误差。举个例子:

假如 我们的视频 只有在10秒和15秒上才有关键帧,这时候我们希望跳转到13秒,调用

av_seek_frame后视频只能跳到10秒或者15秒,因为解码器解码视频必须要有关键帧的信息才能解码,因此av_seek_frame这样设定是合理的,所以我们seek之后还需要执行一些操作来处理这个情况。


这里先说下av_seek_frame中第三个参数flags的值:

官方定义:

#define AVSEEK_FLAG_BACKWARD 1 ///< seek backward
#define AVSEEK_FLAG_BYTE     2 ///< seeking based on position in bytes
#define AVSEEK_FLAG_ANY      4 ///< seek to any frame, even non-keyframes
#define AVSEEK_FLAG_FRAME    8 ///< seeking based on frame number

试验结果:

AVSEEK_FLAG_BACKWARD:若你设置seek时间为1秒,但是只有0秒和2秒上才有I帧,则时间从0秒开始。

AVSEEK_FLAG_ANY:若你设置seek时间为1秒,但是只有0秒和2秒上才有I帧,则时间从2秒开始。

AVSEEK_FLAG_FRAME:若你设置seek时间为1秒,但是只有0秒和2秒上才有I帧,则时间从2秒开始。


注:I帧就是关键帧


于是我们在跳转的时候把第三个参数设置成:AVSEEK_FLAG_BACKWARD

来达到往前面跳转的目的,继续前面的例子,我们的目的是跳转13秒,现在实际是跳到了10秒的位置。此时读取函数正常读取将10~13秒的数据都放入了视频线程。所以视频就从10秒的位置开始播放了,显然这就和我们预期跳转的位置差了3秒。


虽然我们的播放器要求没那么精确,但还是希望完善它。


方法也很简单,首先解码器解码需要关键帧的信息,因此10到13秒的数据还不能丢弃,还是正常放入队列,让解码器解码之后不做显示而是直接丢弃。

同样的方法,再次加一个变量用来记录是否跳转:

(为了不影响主的跳转功能,这里把跳转标志分开)

1
2
3
4
5
     /// 跳转相关的变量    int             seek_req; //跳转标志
     int64_t         seek_pos;  //跳转的位置 -- 微秒
     int              seek_flag_audio; //跳转标志 -- 用于音频线程中
     int              seek_flag_video; //跳转标志 -- 用于视频线程中
     double           seek_time;  //跳转的时间(秒)  值和seek_pos是一样的


然后在读取线程中,跳转完毕后,将这些标志设置上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
     if  (av_seek_frame(is->ic, stream_index, seek_target, AVSEEK_FLAG_BACKWARD) < 0)
     {        
         fprintf (stderr, "%s: error  while  seeking
",is->ic->filename);
    
     else 
     {
        ...
     }
     is->seek_req = 0;
     is->seek_time = is->seek_pos / 1000000.0;
     is->seek_flag_audio = 1;
     is->seek_flag_video = 1;
}


最后在解码线程中,解码完获取到pts后,加入如下语句判断:

(以视频为例,音频也是类似)

1
2
3
4
5
6
7
8
9
10
11
12
         if  (is->seek_flag_video)        {
             //发生了跳转 则跳过关键帧到目的时间的这几帧
            if  (video_pts < is->seek_time)
            {
                av_free_packet(packet);
                continue ;
            }
            else
            {
                is->seek_flag_video = 0;
            }
         }



这样加上之后,跳转功能也完美了。


三、停止功能实现

    我想到这里,大家也应该知道停止功能怎么实现了吧。和暂停功能一样,停止功能也加个变量来判断就好了,怎么做就不多说了,全是逻辑问题,发点时间就可以写出来了。


需要注意的一点是:

    停止播放的时候,需要将SDL的音频设备也关闭了。

因为我们下一次打开的视频文件,其采样率和声道与这次不一定相同,因此每次打开新视频的时候,都需要重新打开音频设备。

 所以停止函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
bool  VideoPlayer::stop( bool  isWait)
{
     if  (mPlayerState == Stop)
     {
         return  false ;
     }
 
     mVideoState.quit = 1;
 
     if  (isWait)
     {
         while (!mVideoState.readThreadFinished || !mVideoState.videoThreadFinished)
         {
             SDL_Delay(10);
         }
     }
 
     ///关闭SDL音频播放设备
     if  (mVideoState.audioID != 0)
     {
         SDL_LockAudio();
         SDL_PauseAudioDevice(mVideoState.audioID,1);
         SDL_UnlockAudio();
     }
 
     mPlayerState = Stop;
     emit sig_StateChanged(Stop);
 
     return  true ;
}


需要注意的是:SDL_PauseAudioDevice会一直阻塞,直到其回调函数返回。因此,关闭SDL音频的时候需要确保回调函数(既这里的audio_callback)可以正常返回。

不巧的是,我们获取队列的函数中有这么一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if  (pkt1) 
{    
     q->first_pkt = pkt1->next;
     if  (!q->first_pkt)
         q->last_pkt = NULL;
     q->nb_packets--;
     q->size -= pkt1->pkt.size;
     *pkt = pkt1->pkt;
     av_free(pkt1);
     ret = 1;
     break ;
else  if  (!block) {
     ret = 0;
     break ;
else  {
     SDL_CondWait(q->cond, q->mutex);  //就是这句了
}


可以看出,当队列是空的时候,获取数据的函数就停在这里,直到有数据放入队列。

而此函数又恰恰是在audio_callback中调用的,如果刚好这时候调用了SDL_PauseAudioDevice函数,2个线程就这样进入死锁了,基本是没救了。

表现出来的就是,停止的时候,程序就卡死了。

当然并不是每次停止都会卡死,因为只有刚好上面这种情况的时候才会发生死锁。

这个问题我调了好久才找到原因,


解决方法却很简单,只要在获取队列数据的地方,改成获取不到数据的时候返回,而不是阻塞。这样就OK了:

1
2
3
4
         if  (packet_queue_get(&is->audioq, pkt, 0) <= 0)        
         {
             return  -1;
         }

详细的,直接看完整的代码吧。


四、添加控制界面

首先在界面上加入几个控制用的按钮:



之后绑定这些按钮到槽函数:

1
2
3
4
5
6
     connect(ui->pushButton_open,SIGNAL(clicked()), this ,SLOT(slotBtnClick()));    
     connect(ui->pushButton_play,SIGNAL(clicked()), this ,SLOT(slotBtnClick()));
     connect(ui->pushButton_pause,SIGNAL(clicked()), this ,SLOT(slotBtnClick()));
     connect(ui->pushButton_stop,SIGNAL(clicked()), this ,SLOT(slotBtnClick()));
 
     connect(ui->horizontalSlider,SIGNAL(sliderMoved( int )), this ,SLOT(slotSliderMoved( int )));


槽函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void  MainWindow::slotBtnClick()
{
     if  (QObject::sender() == ui->pushButton_play)
     {
         mPlayer->play();
     }
     else  if  (QObject::sender() == ui->pushButton_pause)
     {
         mPlayer->pause();
     }
     else  if  (QObject::sender() == ui->pushButton_stop)
     {
         mPlayer->stop( true );
     }
     else  if  (QObject::sender() == ui->pushButton_open)
     {
         QString s = QFileDialog::getOpenFileName(
                    this "选择要播放的文件" ,
                     "/" , //初始目录
                     "视频文件 (*.flv *.rmvb *.avi *.MP4);; 所有文件 (*.*);; " );
         if  (!s.isEmpty())
         {
             s.replace( "/" , "" );
 
             mPlayer->stop( true );  //如果在播放则先停止
 
             mPlayer->setFileName(s);
 
             mTimer->start();
 
         }
     }
}



到这里一个播放器的雏形就做好了:


当然这个界面很丑,但毕竟功能都实现了,可以开心一小会儿了。


另外,这里我们只是简单的加了几个按钮,并没有给按钮做好基本的逻辑控制,比如暂停的时候暂停按钮应该隐藏,类似这样的。


这样的好处是可以随便点击按钮去虐我们的播放器,看他都存在哪些问题,再一步一步完善它。



完整工程下载地址:

http://download.csdn.net/detail/qq214517703/9631101




原文地址:http://blog.yundiantech.com/?log=blog&id=13


你可能感兴趣的:(从零开始学习音视频编程技术)