1. 程式人生 > >ffmpeg入門學習——文件4:建立執行緒

ffmpeg入門學習——文件4:建立執行緒

指導4:建立執行緒

1、概要 上一次我們使用SDL的函式來達到支援音訊播放的效果。每當SDL需要音訊時它會啟動一個執行緒來呼叫我們提供的回撥函式。現在我們對視訊進行同樣的處理。這樣會使程式更加模組化和跟容易協調工作 - 尤其是當我們想往程式碼裡面加入同步功能。那麼我們要從哪裡開始呢? 首先我們注意到我們的主函式處理太多東西了:它執行著事件迴圈,讀取包和處理視訊解碼。所以我們將把這些東西分成幾個部分:我們會建立一個執行緒來負責解包;這個包會叫如到佇列裡面,然後由相關的視訊或者音訊執行緒來讀取這個包。音訊執行緒之前已經按照我們的想法建立好了;由於我們需要自己來播放視訊,因此建立視訊執行緒會有點複雜。我們會把真正播放視訊的程式碼放在主執行緒。不是僅僅在每次迴圈時顯示視訊,而是把視訊播放整合到事件迴圈中。現在的想法是解碼視訊,把結果儲存到另一個佇列中,然後建立一個普通事件(FF_REFRESH_EVENT)加入到事件系統中,接著我們的事件迴圈不斷檢測這個事件。他將會在這個佇列裡面播放下一幀。這裡有一個圖來解釋究竟發生了什麼事情:

主要目的是通過使用SDL_Delay執行緒的事件驅動來控制視訊的移動,我們可以控制下一幀視訊應該在什麼時間在螢幕上顯示。當我們在下一個教程中新增視訊的重新整理時間控制程式碼,就可以使視訊速度播放正常了。

2、簡化程式碼

——> 我們同樣會清理一些程式碼。我們有所有這些視訊和音訊編解碼器的資訊,我們將會加入佇列和緩衝和所有其他的東西。所有致謝東西都是為了一個邏輯單元,也就是視訊。所以我們建立一個大結構體來裝載這些資訊,我們把它叫做VideoState

typedef struct VideoState {
AVFormatContext *pFormatCtx;
intvideoStream, audioStream;
AVStream *audio_st;
PacketQueue audioq;
uint8_t audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2];
unsigned int audio_buf_size;
unsigned int audio_buf_index;
AVPacket audio_pkt;
uint8_t *audio_pkt_data;
intaudio_pkt_size;
AVStream *video_st;
PacketQueue videoq;
VideoPicture pictq[VIDEO_PICTURE_QUEUE_SIZE];
intpictq_size, pictq_rindex, pictq_windex;
SDL_mutex *pictq_mutex;
SDL_cond *pictq_cond;
SDL_Thread *parse_tid;
SDL_Thread *video_tid;
charfilename[1024];
intquit;
} VideoState;

讓我們來看一下我們看到了什麼。首先,我們看到基本資訊 - 視訊和音訊流的格式和引數,和相應的AVStream物件。然後我們看到我們把以下音訊緩衝移動到這個結構體裡面。這些音訊的有關資訊(音訊緩衝,緩衝大小等)都在附近。我們已經給視訊添加了另一個佇列,也為解碼的幀(儲存為overlay)準備了緩衝(會用來作為佇列,我們不需要一個花哨的佇列)。VideoPicture是我們創造的(我們將會在以後看看裡面有什麼東西)。我們同樣注意到結構體還分配指標給我們額外建立的執行緒,退出標誌和視訊的檔名。

——>現在就讓我們回到主函式,看看如何修改我們的程式碼,首先設定VideoState結構體:

1 int main(intargc, char *argv[]) {
2
3 SDL_Event event;
4
5 VideoState *is;
6
7 is = av_mallocz(sizeof(VideoState));

av_mallocz()函式會為我們申請空間而且初始化為全0。 ——>然後我們要初始化為視訊緩衝準備的鎖(pictq)。因為一旦事件驅動呼叫我們的視訊函式 - 視訊函式會從pictq抽出預解碼幀。同時,我們的視訊解碼器會把資訊放進去 - 我們不知道那個動作會先發生。希望你認識到這是一個經典的競爭條件。所以我們要在開始任何執行緒前為其分配空間。同時讓我們把檔名放到VideoState當中。

1 pstrcpy(is->filename, sizeof(is->filename), argv[1]);
2
3 is->pictq_mutex = SDL_CreateMutex();
4 is->pictq_cond = SDL_CreateCond();

pstrcpy(已過期)是ffmpeg中的一個函式,其對strncpy作了一些額外的檢測3、我們的第一個執行緒 讓我們啟動我們的執行緒使工作落到實處吧:

schedule_refresh(is, 40);//某個特定的毫秒數後彈出FF_REFRESH_EVENT事件。這將會反過來呼叫事件佇列裡的視訊重新整理函式
is->parse_tid = SDL_CreateThread(decode_thread, is);
if(!is->parse_tid) {
av_free(is);
return-1;
}

schedule_refresh是一個我們將要定義的函式。它的動作是告訴系統在某個特定的毫秒數後彈出FF_REFRESH_EVENT事件。這將會反過來呼叫事件佇列裡的視訊重新整理函式。但是現在,讓我們分析一下SDL_CreateThread()。

SDL_CreateThread()做的事情是這樣的 - 它生成一個新執行緒能完全訪問原始程序中的記憶體,啟動我們給的執行緒。它同樣會執行使用者定義資料的函式。在這種情況下,我們呼叫decode_thread()並與VideoState結構體連線。上半部分的函式沒什麼新東西;它的工作就是開啟檔案和找到視訊流和音訊流的索引。唯一不同的地方是把格式內容儲存到我們的大結構體中。當我們找到流後,我們呼叫另一個我們將要定義的函式stream_component_open()。這是一個一般的分離的方法,自從我們設定很多相似的視訊和音訊解碼的程式碼,我們通過編寫這個函式來重用它們。

stream_component_open()函式的作用是找到我們的解碼器,設定音訊引數,儲存重要資訊到大結構體中,然後啟動音訊和視訊執行緒。我們還會在這裡設定一些其他引數,例如指定編碼器而不是自動檢測等等,下面就是程式碼:

int stream_component_open(VideoState *is,int stream_index) {
AVFormatContext *pFormatCtx = is->pFormatCtx;
AVCodecContext *codecCtx;
AVCodec *codec;
SDL_AudioSpec wanted_spec, spec;
// -------------------------找到音視訊流-------------------------//
if(stream_index < 0 || stream_index >= pFormatCtx->nb_streams) {
return-1;
}
// Get a pointer to the codec context for the video stream
codecCtx = pFormatCtx->streams[stream_index]->codec;//從AVFormatContext結構體中得到AVCodecContext
if(codecCtx->codec_type == CODEC_TYPE_AUDIO) {//如果是音訊,利用AVCodecContext結構體對對SDL_AudioSpec結構體wanted_spec進行設定
// Set audio settings from codec info
wanted_spec.freq = codecCtx->sample_rate;
/* .... */
wanted_spec.callback = audio_callback;
wanted_spec.userdata = is;
if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {//開啟聲音裝置
fprintf(stderr,"SDL_OpenAudio: %s\n", SDL_GetError());
return-1;
}
}
codec = avcodec_find_decoder(codecCtx->codec_id);//找到解碼器
if(!codec || (avcodec_open(codecCtx, codec) < 0)) {
fprintf(stderr,"Unsupported codec!\n");
return-1;
}
switch(codecCtx->codec_type) {
caseCODEC_TYPE_AUDIO:
is->audioStream = stream_index;
is->audio_st = pFormatCtx->streams[stream_index];
is->audio_buf_size = 0;
is->audio_buf_index = 0;
memset(&is->audio_pkt, 0,sizeof(is->audio_pkt));
packet_queue_init(&is->audioq);//初始化佇列
SDL_PauseAudio(0);//開始播放音訊,如果沒有立即供給足夠的資料,它會播放靜音。
break;
caseCODEC_TYPE_VIDEO:
is->videoStream = stream_index;
is->video_st = pFormatCtx->streams[stream_index];
packet_queue_init(&is->videoq);
is->video_tid =SDL_CreateThread(video_thread, is);
break;
default:
break;
}
}

這跟以前我們寫的程式碼幾乎一樣,只不過現在是包括音訊和視訊。注意到我們建立了大結構體來作為音訊回撥的使用者資料來代替了aCodecCtx。我們同樣儲存流到audio_st和video_st。像建立音訊佇列一樣,我們也增加了視訊佇列。主要是執行視訊和音訊執行緒。就像如下:

1 SDL_PauseAudio(0);
2 break;
3
4 /* ...... */
5
6 is->video_tid = SDL_CreateThread(video_thread, is);

我們還記得之前SDL_PauseAudio()的作用,還有SDL_CreateThread()跟以前的用法一樣。我們會回到video_thread()函式。 在這之前,讓我們回到decode_thread()函式的下半部分。基本上就是一個迴圈來讀取包和把它放到相應的佇列中:

01 for(;;) {
02 if(is->quit) {
03 break;
04 }
05 // seek stuff goes here
06 if(is->audioq.size > MAX_AUDIOQ_SIZE ||
07 is->videoq.size > MAX_VIDEOQ_SIZE) {
08 SDL_Delay(10);
09 continue;
10 }
11 if(av_read_frame(is->pFormatCtx, packet) < 0) {
12 if(url_ferror(&pFormatCtx->pb) == 0) {
13 SDL_Delay(100);/* no error; wait for user input */
14 continue;
15 else{
16 break;
17 }
18 }
19 // Is this a packet from the video stream?
20 if(packet->stream_index == is->videoStream) {
21 packet_queue_put(&is->videoq, packet);
22 elseif(packet->stream_index == is->audioStream) {
23 packet_queue_put(&is->audioq, packet);
24 else{
25 av_free_packet(packet);
26 }
27 }

這裡沒有新的東西,除了我們的音訊和視訊佇列定義了一個最大值,還有我們加入了檢測讀取錯誤的函式。格式內容裡面有一個叫做pb的ByteIOContext結構體。ByteIOContext十一個儲存所有低階檔案資訊的結構體。url_ferror檢測結構體在讀取檔案時石油出現某些錯誤。 經過我們的for迴圈,我們等待程式結束或者通知我們已經結束。這些程式碼指導我們如何推送事件 - 一些我們以後用來顯示視訊的東西。

while(!is->quit) {
SDL_Delay(100);
}
fail:
if(1){
SDL_Event event;
event.type = FF_QUIT_EVENT;
event.user.data1 = is;
SDL_PushEvent(&event);
}
return0;

我們通過SDL定義的一個巨集來獲取使用者事件的值。第一個使用者事件應該分配給SDL_USEREVENT,下一個分配給SDL_USEREVENT + 1,如此類推。FF_QUIT_EVENT在SDL_USEREVENT + 2中定義。如果我們喜歡,我們同樣可以傳遞使用者事件,這裡我們把我們的指標傳遞給了一個大結構體。最後我們呼叫SDL_PushEvent()。在我們的迴圈分流中,我們只是把SDL_QUIT_EVENT部分放進去。我們還會看到事件迴圈的更多細節;現在,只是保證當我們推送FF_QUIT_EVENT時,我們會得到它和quit值變為1。

4、獲得幀:視訊執行緒 準備好解碼後,我們開啟視訊執行緒:從視訊佇列裡面讀取包,把視訊解碼為幀,然後呼叫queue_picture函式來把幀放進picture佇列:

int video_thread(void*arg)

{

VideoState *is = (VideoState *)arg;
AVPacket pkt1, *packet = &pkt1;
intlen1, frameFinished;
AVFrame *pFrame;
pFrame = avcodec_alloc_frame();

for(;;)

{

if(packet_queue_get(&is->videoq, packet, 1) < 0)

{

break;
}
// Decode video frame
len1 = avcodec_decode_video(is->video_st->codec, pFrame, &frameFinished, //視訊解碼
packet->data, packet->size);
// Did we get a video frame?

if(frameFinished)

{

if(queue_picture(is, pFrame) < 0)

{

break;
}
}
av_free_packet(packet);
}
av_free(pFrame);
return0;
}

大部分函式在這點上應該是相似的。我們已經把avcodec_decode_video函式移動到這裡,只是替換了一些引數;例如,我們的大結構體裡面有AVStream,所以我們從那裡得到我們的編解碼器。我們持續地從視訊佇列裡面取包,知道某人告訴我們該結束或者我們遇到錯誤。

5、幀排隊

一起來看看我們picture佇列裡面用來儲存我們解碼幀的函式pFrame。由於我們的picture佇列是SDL overlay(大概是為了視訊顯示儘量少的計算),我們需要把轉換的幀儲存在picture佇列裡面 :

1 typedef struct VideoPicture {
2 SDL_Overlay *bmp;
3 intwidth, height; /* source height & width */
4 intallocated;
5 } VideoPicture;

我們的大結構體有緩衝來儲存他們。然而,我們需要自己分配SDL_Overlay(注意到allocated標誌用來標示我們是否已經分配了記憶體)。

使用這個佇列我們需要兩個指標 - 寫索引和讀索引。我們同樣記錄著緩衝裡面實際上有多少圖片。為了寫佇列,我們第一次要等待緩衝清空以保證有空間儲存VideoPicture。然後我們檢測我們是否為寫索引申請了overlay。如果沒有,我們需要申請一些空間。如果視窗的大小改變了,我們同樣需要重新申請緩衝。然而,為了避免鎖問題,我們不會在這裡申請(我還不太確定為什麼;我相信要避免在不同執行緒呼叫SDL overlay函式。)

int queue_picture(VideoState *is, AVFrame *pFrame)

{

VideoPicture *vp;
intdst_pix_fmt;
AVPicture pict;
/* wait until we have space for a new pic */
SDL_LockMutex(is->pictq_mutex);
while(is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE &&

!is->quit)

{

SDL_CondWait(is->pictq_cond, is->pictq_mutex);
}
SDL_UnlockMutex(is->pictq_mutex);
if(is->quit)
return-1;
// windex is set to 0 initially
vp = &is->pictq[is->pictq_windex];
/* allocate or resize the buffer! */
if(!vp->bmp ||vp->width != is->video_st->codec->width ||vp->height != is->video_st->codec->height)
{
SDL_Event event;
vp->allocated = 0;
/* we have to do it in the main thread */
event.type = FF_ALLOC_EVENT;
event.user.data1 = is;
SDL_PushEvent(&event);
/* wait until we have a picture allocated */
SDL_LockMutex(is->pictq_mutex);

while(!vp->allocated && !is->quit)

{

SDL_CondWait(is->pictq_cond, is->pictq_mutex);
}
SDL_UnlockMutex(is->pictq_mutex);
if(is->quit) {
return-1;
}
}

當我們想退出時,退出機制就像我們之前看到的那樣處理。我們已經定義了FF_ALLOC_EVENT為SDL_USEREVENT。我們推送事件然後等待條件變數分配函式執行。 讓我們來看看我們是怎麼改變事件迴圈的:

1 for(;;) {
2 SDL_WaitEvent(&event);
3 switch(event.type) {
4 /* ... */
5 caseFF_ALLOC_EVENT:
6 alloc_picture(event.user.data1);
7 break;

記住event.user.data1就是我們的大結構體。這已經足夠簡單了。讓我們來看看alloc_picture()函式:

01 void alloc_picture(void*userdata) {
02
03 VideoState *is = (VideoState *)userdata;
04 VideoPicture *vp;
05
06 vp = &is->pictq[is->pictq_windex];
07 if(vp->bmp) {
08 // we already have one make another, bigger/smaller
09 SDL_FreeYUVOverlay(vp->bmp);
10 }
11 // Allocate a place to put our YUV image on that screen
12 vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width,
13 is->video_st->codec->height,
14 SDL_YV12_OVERLAY,
15 screen);
16 vp->width = is->video_st->codec->width;
17 vp->height = is->video_st->codec->height;
18
19 SDL_LockMutex(is->pictq_mutex);
20 vp->allocated = 1;
21 SDL_CondSignal(is->pictq_cond);
22 SDL_UnlockMutex(is->pictq_mutex);
23 }

你應該認識到我們已經把SDL_CreateYUVOverlay移動到這裡。此程式碼現在應該是相當不言自明。記住我們把寬度和高度儲存到VideoPicture裡面,因為由於某些原因我們不想改變視訊的尺寸。 好了,我們解決了所有東西,現在我們的YUV overlay已經分配好記憶體,準備接收圖片了。讓我們回到queue_picture來看看把幀複製到overlay當中,你應該記得這部分內容的:

01 int queue_picture(VideoState *is, AVFrame *pFrame) {
02
03 /* Allocate a frame if we need it... */
04 /* ... */
05 /* We have a place to put our picture on the queue */
06
07 if(vp->bmp) {
08
09 SDL_LockYUVOverlay(vp->bmp);
10
11 dst_pix_fmt = PIX_FMT_YUV420P;
12 /* point pict at the queue */
13
14 pict.data[0] = vp->bmp->pixels[0];
15 pict.data[1] = vp->bmp->pixels[2];
16 pict.data[2] = vp->bmp->pixels[1];
17
18 pict.linesize[0] = vp->bmp->pitches[0];
19 pict.linesize[1] = vp->bmp->pitches[2];
20 pict.linesize[2] = vp->bmp->pitches[1];
21
22 // Convert the image into YUV format that SDL uses
23 img_convert(&pict, dst_pix_fmt,
24 (AVPicture *)pFrame, is->video_st->codec->pix_fmt,
25 is->video_st->codec->width, is->video_st->codec->height);
26
27 SDL_UnlockYUVOverlay(vp->bmp);
28 /* now we inform our display thread that we have a pic ready */
29 if(++is->pictq_windex == VIDEO_PICTURE_QUEUE_SIZE) {
30 is->pictq_windex = 0;
31 }
32 SDL_LockMutex(is->pictq_mutex);
33 is->pictq_size++;
34 SDL_UnlockMutex(is->pictq_mutex);
35 }
36 return0;
37 }

這部分的主要功能就是我們之前所用的簡單地把幀填充到YUV overlay。最後把值加到隊列當中。佇列的工作是持續新增直到滿,和裡面有什麼就讀取什麼。因此所有東西都基於is->pictq_size這個值,需要我們來鎖住它。所以現在工作是增加寫指標(有需要的話翻轉它),然後鎖住佇列增加其大小。現在我們的讀索引知道佇列裡面有更多的資訊,如果佇列滿了,我們的寫索引會知道的。

6、播放視訊

這就是我們的視訊執行緒!現在我們已經包裹起所有鬆散的執行緒,除了這個 - 還記得我們呼叫schedule_refresh()函式嗎?讓我們來看看它實際上做了什麼工作:

1 /* schedule a video refresh in 'delay' ms */
2 static void schedule_refresh(VideoState *is, intdelay) {
3 SDL_AddTimer(delay, sdl_refresh_timer_cb, is);
4 }

SDL_AddTimer()是一個SDL函式,在一個特定的毫秒數裡它簡單地回調了使用者指定函式(可選擇攜帶一些使用者資料)。我們用這個函式來計劃視訊的更新 - 每次我們呼叫這個函式,它會設定一個時間,然後會觸發一個事件,然後我們的主函式會呼叫函式來從picture佇列里拉出一幀然後顯示它! 不過首先,讓我們來觸發事件。它會發送:

1 static Uint32 sdl_refresh_timer_cb(Uint32 interval, void*opaque) {
2 SDL_Event event;
3 event.type = FF_REFRESH_EVENT;
4 event.user.data1 = opaque;
5 SDL_PushEvent(&event);
6 return0; /* 0 means stop timer */
7 }

這裡就是相似的事件推送。FF_REFRESH_EVENT在這裡的定義是SDL_USEREVENT + 1。有一個地方需要注意的是當我們返回0時,SDL會停止計時器,回撥將不再起作用。 現在我們推送FF_REFRESH_EVENT,我們需要在事件迴圈中處理它:

1 for(;;) {
2
3 SDL_WaitEvent(&event);
4 switch(event.type) {
5 /* ... */
6 caseFF_REFRESH_EVENT:
7 video_refresh_timer(event.user.data1);
8 break;

然後呼叫這個函式,將會把資料從picture佇列裡面拉出來:

01 void video_refresh_timer(void*userdata) {
02
03 VideoState *is = (VideoState *)userdata;
04 VideoPicture *vp;
05
06 if(is->video_st) {
07 if(is->pictq_size == 0) {
08 schedule_refresh(is, 1);
09 else{
10 vp = &is->pictq[is->pictq_rindex];
11 /* Timing code goes here */
12
13 schedule_refresh(is, 80);
14
15 /* show the picture! */
16 video_display(is);
17
18 /* update queue for next picture! */
19 if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {
20 is->pictq_rindex = 0;
21 }
22 SDL_LockMutex(is->pictq_mutex);
23 is->pictq_size--;
24 SDL_CondSignal(is->pictq_cond);
25 SDL_UnlockMutex(is->pictq_mutex);
26 }
27 else{
28 schedule_refresh(is, 100);
29 }
30 }

現在,這個函式就非常簡單明瞭了:它會從佇列裡面拉出資料,設定下一幀播放時間,呼叫vidoe_display來使視訊顯示到螢幕中,佇列計數值加1,然後減小它的尺寸。你會注意到我們沒有對vp做任何動作,這裡解析為什麼:在之後,我們會使用訪問時序資訊來同步視訊和音訊。看看那個“這裡的時序程式碼”的地方,我們會找到我們應該以讀快的速度來播放視訊的下一幀,然後把值放到schedule_refresh()函式裡面。現在我們只是設了一個固定值80。技術上,你可以猜測和檢驗這個值,然後重編你想看的所有電影,但是 1.過一段時間它會變 2.這是很笨的方法。之後我們會回到這個地方。 我們已經差不多完成了;我們還剩下最後一樣東西要做:播放視訊!這裡就是視訊播放的函式:

01 void video_display(VideoState *is) {
02
03 SDL_Rect rect;
04 VideoPicture *vp;
05 AVPicture pict;
06 floataspect_ratio;
07 intw, h, x, y;
08 inti;
09
10 vp = &is->pictq[is->pictq_rindex];
11 if(vp->bmp) {
12 if(is->video_st->codec->sample_aspect_ratio.num == 0) {
13 aspect_ratio = 0;
14 else{
15 aspect_ratio = av_q2d(is->video_st->codec->sample_aspect_ratio) *
16 is->video_st->codec->width / is->video_st->codec->height;
17 }
18 if(aspect_ratio <= 0.0) {
19 aspect_ratio = (float)is->video_st->codec->width /
20 (float)is->video_st->codec->height;
21 }
22 h = screen->h;
23 w = ((int)rint(h * aspect_ratio)) & -3;
24 if(w > screen->w) {
25 w = screen->w;
26 h = ((int)rint(w / aspect_ratio)) & -3;
27 }
28 x = (screen->w - w) / 2;
29 y = (screen->h - h) / 2;
30
31 rect.x = x;
32 rect.y = y;
33 rect.w = w;
34 rect.h = h;
35 SDL_DisplayYUVOverlay(vp->bmp, &rect);
36 }
37 }

由於我們的螢幕尺寸可能為任何尺寸(我們設定為640x480,使用者有方法可以重新設定尺寸),我們需要動態指出我們需要多大的一個矩形區域。所以首先我們需要指定我們視訊的長寬比,也就是寬除以高的值。一些編解碼器會有一個奇樣本長寬比,也就是一個畫素或者一個樣本的寬高比。由於我們的編解碼的長寬值是按照畫素來計算的,所以實際的寬高比等於樣本寬高比某些編解碼器的寬高比為0,表示每個畫素的寬高比為 1x1。然後我們把視訊縮放到儘可能大的尺寸。這裡的 & -3表示與 -3做與運算,實際上是讓他們4位元組對齊。然後我們把電影居中,然後呼叫SDL_DisplayYUVOverlay()。 那麼結果怎樣?我們做完了嗎?我們仍然要重寫音訊程式碼來使用我們新的VideoStruct,但那只是瑣碎的改變,你可以參考示例程式碼。最後我們需啊喲做的事情是改變ffmpeg內部的退出回撥函式變為我們自己的退出回撥函式。

VideoState *global_video_state;
int decode_interrupt_cb(void) {
return(global_video_state && global_video_state->quit);
}

我們在主函式裡面設定global_video_state這個大結構體。 這就是了!讓我們來編譯它:

1 sdl-config --cflags --libs
2 gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lSDL -lz -lm

享受你的未同步電影吧!下一節我們會使視訊播放器真正地工作起來。