国产xxxx99真实实拍_久久不雅视频_高清韩国a级特黄毛片_嗯老师别我我受不了了小说

資訊專欄INFORMATION COLUMN

Canvas + WebSocket + Redis 實現(xiàn)一個視頻彈幕

gekylin / 2131人閱讀

摘要:創(chuàng)建彈幕功能的類及基本參數(shù)處理布局時需要注意的默認(rèn)寬為,高為,我們要保證完全覆蓋整個視頻,需要讓與寬高相等。因為我們不確定每一個使用該功能的視頻的寬高都是一樣的,所以畫布的寬高并沒有通過來設(shè)置,而是通過在類創(chuàng)建實例初始化屬性的時候動態(tài)設(shè)置。


閱讀原文


頁面布局

首先,我們需要實現(xiàn)頁面布局,在根目錄創(chuàng)建 index.html 布局中我們需要有一個 video 多媒體標(biāo)簽引入我們的本地視頻,添加輸入彈幕的輸入框、確認(rèn)發(fā)送的按鈕、顏色選擇器、字體大小滑動條,創(chuàng)建一個 style.css 來調(diào)整頁面布局的樣式,這里我們順便創(chuàng)建一個 index.js 文件用于后續(xù)實現(xiàn)我們的核心邏輯,先引入到頁面當(dāng)中。

HTML 布局代碼如下:





    
    
    視頻彈幕


    

Canvas + WebSocket + Redis 實現(xiàn)視頻彈幕

CSS 樣式代碼如下:

/* 文件:style.css */
#cantainer {
    text-align: center;
}
#content {
    width: 640px;
    margin: 0 auto;
    position: relative;
}
#canvas {
    position: absolute;
}
video {
    width: 640px;
    height: 360px;
}
input {
    vertical-align: middle;
}

布局效果如下圖:


定義接口,構(gòu)造假數(shù)據(jù)

我們彈幕中的彈幕數(shù)據(jù)正常情況下應(yīng)該是通過與后臺數(shù)據(jù)交互請求回來,所以我們需要先定義數(shù)據(jù)接口,并構(gòu)造假數(shù)據(jù)來實現(xiàn)前端邏輯。

數(shù)據(jù)字段定義:

value:表示彈幕的內(nèi)容(必填)

time:表示彈幕出現(xiàn)的時間(必填)

speed:表示彈幕移動的速度(選填)

color:表示彈幕文字的顏色(選填)

fontSize:表示彈幕的字體大?。ㄟx填)

opacity:表示彈幕文字的透明度(選填)

上面的 valuetime 是必填參數(shù),其他的選填參數(shù)可以在前端設(shè)置默認(rèn)值。

前端定義的假數(shù)據(jù)如下:

// 文件:index.js
let data = [
    {
        value: "這是第一條彈幕",
        speed: 2,
        time: 0,
        color: "red",
        fontSize: 20
    },
    {
        value: "這是第二條彈幕",
        time: 1
    }
];


實現(xiàn)前端彈幕的邏輯

我們希望是把彈幕封裝成一個功能,只要有需要的地方就可以使用,從而實現(xiàn)復(fù)用,那么不同的地方使用這個功能通常的方式是 new 一個實例,傳入當(dāng)前使用該功能對應(yīng)的參數(shù),我們也使用這種方式來實現(xiàn),所以我們需要封裝一個統(tǒng)一的構(gòu)造函數(shù)或者類,參數(shù)為當(dāng)前的 canvas 元素、video 元素和一個 options 對象,options 里面的 data 屬性為我們的彈幕數(shù)據(jù),之所以不直接傳入 data 是為了后續(xù)參數(shù)的擴(kuò)展,嚴(yán)格遵循開放封閉原則,這里我們就統(tǒng)一使用 ES6 的 class 類來實現(xiàn)。

1、創(chuàng)建彈幕功能的類及基本參數(shù)處理

布局時需要注意 Canvas 的默認(rèn)寬為 300px,高為 150px,我們要保證 Canvas 完全覆蓋整個視頻,需要讓 Canvas 與 video 寬高相等。
因為我們不確定每一個使用該功能的視頻的寬高都是一樣的,所以 Canvas 畫布的寬高并沒有通過 CSS 來設(shè)置,而是通過 JS 在類創(chuàng)建實例初始化屬性的時候動態(tài)設(shè)置。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);
    }
}

應(yīng)該掛在實例上的屬性除了有當(dāng)前的 canvas 元素、video 元素、彈幕數(shù)據(jù)的默認(rèn)選項以及彈幕數(shù)據(jù)之外,還應(yīng)該有一個代表當(dāng)前是否渲染彈幕的參數(shù),因為視頻暫停的時候,彈幕也是暫停的,所以沒有重新渲染,因為是否暫停與彈幕是否渲染的狀態(tài)是一致的,所以我們這里就用 isPaused 參數(shù)來代表當(dāng)前是否暫?;蛑匦落秩緩椖唬殿愋蜑椴紶栔?。

2、創(chuàng)建構(gòu)造每一條彈幕的類

我們知道,后臺返回給我們的彈幕數(shù)據(jù)是一個數(shù)組,這個數(shù)組里的每一個彈幕都是一個對象,而對象上有著這條彈幕的信息,如果我們需要在每一個彈幕對象上再加一些新的信息或者在每一個彈幕對象的處理時用到了當(dāng)前彈幕功能類 CanvasBarrage 實例的一些屬性值,取值顯然是不太方便的,這樣為了后續(xù)方便擴(kuò)展,遵循開放封閉原則,我們把每一個彈幕的對象轉(zhuǎn)變成同一個類的實例,所以我們創(chuàng)建一個名為 Barrage 的類,讓我們每一條彈幕的對象進(jìn)入這個類里面走一遭,掛上一些擴(kuò)展的屬性。

// 文件:index.js
class Barrage {
    constructor(item, ctx) {
        this.value = item.value; // 彈幕的內(nèi)容
        this.time = item.time; // 彈幕出現(xiàn)的時間
        this.item = item; // 每一個彈幕的數(shù)據(jù)對象
        this.ctx = ctx; // 彈幕功能類的執(zhí)行上下文
    }
}

在我們的 CanvasBarrage 類上有一個存儲彈幕數(shù)據(jù)的數(shù)組 data,此時我們需要給 CanvasBarrage 增加一個屬性用來存放 “加工” 后的每條彈幕對應(yīng)的實例。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // ********** 以下為新增代碼 **********
        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));
        // ********** 以上為新增代碼 **********
    }
}

其實通過上面操作以后,我們相當(dāng)于把 data 里面的每一條彈幕對象轉(zhuǎn)換成了一個 Barrage 類的一個實例,把當(dāng)前的上下文 this 傳入后可以隨時在每一個彈幕實例上獲取 CanvasBarrage 類實例的屬性,也方便我們后續(xù)擴(kuò)展方法,遵循這種開放封閉原則的方式開發(fā),意義是不言而喻的。

3、在 CanvasBarrage 類實現(xiàn)渲染所有彈幕的 render 方法

CanvasBarragerender 方法是在創(chuàng)建彈幕功能實例的時候應(yīng)該渲染 Canvas 所以應(yīng)該在 CanvasBarrage 中調(diào)用,在 render 內(nèi)部,每一次渲染之前都應(yīng)該先將 Canvas 畫布清空,所以需要給當(dāng)前的 CanvasBarrage 類新增一個屬性用于存儲 Canvas 畫布的內(nèi)容。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));

        // ********** 以下為新增代碼 **********
        // Canvas 畫布的內(nèi)容
        this.context = canvas.getContext("2d");

        // 渲染所有的彈幕
        this.render();
        // ********** 以上為新增代碼 **********
    }

    // ********** 以下為新增代碼 **********
    render() {
        // 渲染整個彈幕
        // 第一次先進(jìn)行清空操作,執(zhí)行渲染彈幕,如果沒有暫停,繼續(xù)渲染
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 渲染彈幕
        this.renderBarrage();
        if (this.isPaused == false) {
            // 遞歸渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }
    // ********** 以上為新增代碼 **********
}

在上面的 CanvasBarragerender 函數(shù)中,清空時由于 Canvas 性能比較好,所以將整個畫布清空,所以從坐標(biāo) (0, 0) 點(diǎn),清空的寬高為整個 Canvas 畫布的寬高。

只要視頻是在播放狀態(tài)應(yīng)該不斷的調(diào)用 render 方法實現(xiàn)清空畫布、渲染彈幕、判斷是否暫停,如果非暫停狀態(tài)繼續(xù)渲染,所以我們用到了遞歸調(diào)用 render 去不斷的實現(xiàn)渲染,但是遞歸時如果直接調(diào)用 render,性能特別差,程序甚至?xí)斓簦酝@種情況我們會在遞歸外層加一個 setTimeout 來定義一個短暫的遞歸時間,但是這個過程類似于動畫效果,如果使用 setTimeout 其實是將同步代碼轉(zhuǎn)成了異步執(zhí)行,會增加不確定性導(dǎo)致畫面出現(xiàn)卡頓的現(xiàn)象。

這里我們使用 H5 的新 API requestAnimationFrame,可以在平均 1/60 S 內(nèi)幫我執(zhí)行一次該方法傳入的回調(diào),我們直接把 render 函數(shù)作為回調(diào)函數(shù)傳入 requestAnimationFrame,該方法是按照幀的方式執(zhí)行,動畫流暢,需要注意的是,render 函數(shù)內(nèi)使用了 this,所以應(yīng)該處理一下 this 指向問題。

由于我們使用面向?qū)ο蟮姆绞?,所以渲染彈幕的具體細(xì)節(jié),我們抽離出一個多帶帶的方法 renderBarrage,接下來看一下 renderBarrage 的實現(xiàn)。

4、CanvasBarrage 類 render 內(nèi)部 renderBarrage 的實現(xiàn)
// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));

        // Canvas 畫布的內(nèi)容
        this.context = canvas.getContext("2d");

        // 渲染所有的彈幕
        this.render();
    }
    render() {
        // 渲染整個彈幕
        // 第一次先進(jìn)行清空操作,執(zhí)行渲染彈幕,如果沒有暫停,繼續(xù)渲染
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 渲染彈幕
        this.renderBarrage();
        if (this.isPaused == false) {
            // 遞歸渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }

    // ********** 以下為新增代碼 **********
    renderBarrage() {
        // 將數(shù)組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執(zhí)行渲染此彈幕
        let time = this.video.currentTime;
        this.barrages.forEach(barrage => {
            // 當(dāng)視頻時間大于等于了彈幕設(shè)置的時間,那么開始渲染(時間都是以秒為單位)
            if (time >= barrage.time) {
                // 初始化彈幕的各個參數(shù),只有在彈幕將要出現(xiàn)的時候再去初始化,節(jié)省性能,初始化后再進(jìn)行繪制
                // 如果沒有初始化,先去初始化一下
                if (!barrage.isInited) {
                    // 初始化后下次再渲染就不需要再初始化了,所以創(chuàng)建一個標(biāo)識 isInited
                    barrage.init();
                    barrage.isInited = true;
                }
            }
        });
    }
    // ********** 以上為新增代碼 **********
}

此處的 renderBarrage 方法內(nèi)部主要對每一條彈幕實例所設(shè)置的出現(xiàn)時間和視頻的播放時間做對比,如果視頻的播放時間大于等于了彈幕出現(xiàn)的時間,說明彈幕需要繪制在 Canvas 畫布內(nèi)。

之前我們的每一條彈幕實例的屬性可能不全,彈幕的其他未傳參數(shù)并沒有初始化,所以為了最大限度的節(jié)省性能,我們在彈幕該第一次繪制的時候去初始化參數(shù),等到視頻播放的時間變化再去重新繪制時,不再初始化參數(shù),所以初始化參數(shù)的方法放在了判斷彈幕出現(xiàn)時間的條件里面執(zhí)行,又設(shè)置了代表彈幕實例是不是初始化了的參數(shù) isInited,初始化函數(shù) init 執(zhí)行過一次后,馬上修改 isInited 的值,保證只初始化參數(shù)一次。

renderBarrage 方法中我們可以看出來,其實我們是循環(huán)了專門存放每一條彈幕實例(Barrage 類的實例)的數(shù)組,我們在內(nèi)部用實例去調(diào)用的方法 init 應(yīng)該是在 Barrage 類的原型上,下面我們?nèi)?Barrage 類上實現(xiàn) init 的邏輯。

5、Barrage 類 init 的實現(xiàn)
// 文件:index.js
class Barrage {
    constructor(item, ctx) {
        this.value = item.value; // 彈幕的內(nèi)容
        this.time = item.time; // 彈幕出現(xiàn)的時間
        this.item = item; // 每一個彈幕的數(shù)據(jù)對象
        this.ctx = ctx; // 彈幕功能類的執(zhí)行上下文
    }

    // ********** 以下為新增代碼 **********
    init() {
        this.opacity = this.item.opacity || this.ctx.opacity;
        this.color = this.item.color || this.ctx.color;
        this.fontSize = this.item.fontSize || this.ctx.fontSize;
        this.speed = this.item.speed || this.ctx.speed;

        // 求自己的寬度,目的是用來校驗當(dāng)前是否還要繼續(xù)繪制(邊界判斷)
        let span = document.createElement("span");

        // 能決定寬度的只有彈幕的內(nèi)容和文字的大小,和字體,字體默認(rèn)為微軟雅黑,我們就不做設(shè)置了
        span.innerText = this.value;
        span.style.font = this.fontSize + "px "Microsoft YaHei";

        // span 為行內(nèi)元素,取不到寬度,所以我們通過定位給轉(zhuǎn)換成塊級元素
        span.style.position = "absolute";

        document.body.appendChild(span); // 放入頁面
        this.width = span.clientWidth; // 記錄彈幕的寬度
        document.body.removeChild(span); // 從頁面移除

        // 存儲彈幕出現(xiàn)的橫縱坐標(biāo)
        this.x = this.ctx.canvas.width;
        this.y = this.ctx.canvas.height;

        // 處理彈幕縱向溢出的邊界處理
        if (this.y < this.fontSize) {
            this.y = this.fontSize;
        }
        if (this.y > this.ctx.canvas.height - this.fontSize) {
            this.y = this.ctx.canvas.height - this.fontSize;
        }
    }
    // ********** 以上為新增代碼 **********
}

在上面代碼的 init 方法中我們其實可以看出,每條彈幕實例初始化的時候初始的信息除了之前說的彈幕的基本參數(shù)外,還獲取了每條彈幕的寬度(用于后續(xù)做彈幕是否已經(jīng)完全移出屏幕的邊界判斷)和每一條彈幕的 xy 軸方向的坐標(biāo)并為了防止彈幕在 y 軸顯示不全做了邊界處理。

6、實現(xiàn)每條彈幕的渲染和彈幕移除屏幕的處理

我們當(dāng)時在 CanvasBarrage 類的 render 方法中的渲染每個彈幕的方法 renderBarrage中(原諒這么啰嗦,因為到現(xiàn)在內(nèi)容已經(jīng)比較多,說的具體一點(diǎn)方便知道是哪個步驟,哈哈)只做了對每一條彈幕實例的初始化操作,并沒有渲染在 Canvas 畫布中,這時我們主要做兩部操作,實現(xiàn)每條彈幕渲染在畫布中和左側(cè)移出屏幕不再渲染的邊界處理。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));

        // Canvas 畫布的內(nèi)容
        this.context = canvas.getContext("2d");

        // 渲染所有的彈幕
        this.render();
    }
    render() {
        // 渲染整個彈幕
        // 第一次先進(jìn)行清空操作,執(zhí)行渲染彈幕,如果沒有暫停,繼續(xù)渲染
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 渲染彈幕
        this.renderBarrage();
        if (this.isPaused == false) {
            // 遞歸渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }
    renderBarrage() {
        // 將數(shù)組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執(zhí)行渲染此彈幕
        let time = this.video.currentTime;
        this.barrages.forEach(barrage => {
            // ********** 以下為改動的代碼 **********
            // 當(dāng)視頻時間大于等于了彈幕設(shè)置的時間,那么開始渲染(時間都是以秒為單位)
            if (!barrage.flag && time >= barrage.time) {
                // ********** 以上為改動的代碼 **********

                // 初始化彈幕的各個參數(shù),只有在彈幕將要出現(xiàn)的時候再去初始化,節(jié)省性能,初始化后再進(jìn)行繪制
                // 如果沒有初始化,先去初始化一下
                if (!barrage.isInited) {
                    // 初始化后下次再渲染就不需要再初始化了,所以創(chuàng)建一個標(biāo)識 isInited
                    barrage.init();
                    barrage.isInited = true;
                }

                // ********** 以下為新增代碼 **********
                barrage.x -= barrage.speed;
                barrage.render(); // 渲染該條彈幕
                if (barrage.x < barrage.width * -1) {
                    barrage.flag = true; // 是否出去了,出去了,做停止渲染的操作
                }
                // ********** 以上為新增代碼 **********
            }
        });
    }
}

每個彈幕實例都有一個 speed 屬性,該屬性代表著彈幕移動的速度,換個說法其實就是每次減少的 x 軸的差值,所以我們其實是通過改變 x 軸的值再重新渲染而實現(xiàn)彈幕的左移,我們創(chuàng)建了一個標(biāo)識 flag 掛在每個彈幕實例下,代表是否已經(jīng)離開屏幕,如果離開則更改 flag 的值,使外層的 CanvasBarrage 類的 render 函數(shù)再次遞歸時不進(jìn)入渲染程序。

每一條彈幕具體是怎么渲染的,通過代碼可以看出每個彈幕實例在 x 坐標(biāo)改變后都調(diào)用了實例方法 render 函數(shù),注意此 render 非彼 render,該 render 函數(shù)屬于 Barrage 類,目的是為了渲染每一條彈幕,而 CanvasBarrage 類下的 render,是為了在視頻時間變化時清空并重新渲染整個 Canvas 畫布。

7、Barrage 類下的 render 方法的實現(xiàn)
// 文件:index.js
class Barrage {
    constructor(item, ctx) {
        this.value = item.value; // 彈幕的內(nèi)容
        this.time = item.time; // 彈幕出現(xiàn)的時間
        this.item = item; // 每一個彈幕的數(shù)據(jù)對象
        this.ctx = ctx; // 彈幕功能類的執(zhí)行上下文
    }
    init() {
        this.opacity = this.item.opacity || this.ctx.opacity;
        this.color = this.item.color || this.ctx.color;
        this.fontSize = this.item.fontSize || this.ctx.fontSize;
        this.speed = this.item.speed || this.ctx.speed;

        // 求自己的寬度,目的是用來校驗當(dāng)前是否還要繼續(xù)繪制(邊界判斷)
        let span = document.createElement("span");

        // 能決定寬度的只有彈幕的內(nèi)容和文字的大小,和字體,字體默認(rèn)為微軟雅黑,我們就不做設(shè)置了
        span.innerText = this.value;
        span.style.font = this.fontSize + "px "Microsoft YaHei";

        // span 為行內(nèi)元素,取不到寬度,所以我們通過定位給轉(zhuǎn)換成塊級元素
        span.style.position = "absolute";

        document.body.appendChild(span); // 放入頁面
        this.width = span.clientWidth; // 記錄彈幕的寬度
        document.body.removeChild(span); // 從頁面移除

        // 存儲彈幕出現(xiàn)的橫縱坐標(biāo)
        this.x = this.ctx.canvas.width;
        this.y = this.ctx.canvas.height;

        // 處理彈幕縱向溢出的邊界處理
        if (this.y < this.fontSize) {
            this.y = this.fontSize;
        }
        if (this.y > this.ctx.canvas.height - this.fontSize) {
            this.y = this.ctx.canvas.height - this.fontSize;
        }
    }

    // ********** 以下為新增代碼 **********
    render() {
        this.ctx.context.font = this.fontSize + "px "Microsoft YaHei"";
        this.ctx.context.fillStyle = this.color;
        this.ctx.context.fillText(this.value, this.x, this.y);
    }
    // ********** 以上為新增代碼 **********
}

從上面新增代碼我們可以看出,其實 Barrage 類的 render 方法只是將每一條彈幕的字號、顏色、內(nèi)容、坐標(biāo)等屬性通過 Canvas 的 API 添加到了畫布上。

8、實現(xiàn)播放、暫停事件

還記得我們的 CanvasBarrage 類里面有一個屬性 isPaused,屬性值控制了我們是否遞歸渲染,這個屬性與視頻暫停的狀態(tài)是一致的,我們在播放的時候,彈幕不斷的清空并重新繪制,當(dāng)暫停的時候彈幕也應(yīng)該跟著暫停,說白了就是不在調(diào)用 CanvasBarrage 類的 render 方法,其實就是在暫停、播放的過程中不斷的改變 isPaused 的值即可。

還記得我們之前構(gòu)造的兩條假數(shù)據(jù) data 吧,接下來我們添加播放、暫停事件,來嘗試使用一下我們的彈幕功能。

// 文件:index.js
// 實現(xiàn)一個簡易選擇器,方便獲取元素,后面獲取元素直接調(diào)用 $
const $ = document.querySelector.bind(document);

// 獲取 Canvas 元素和 Video 元素
let canvas = $("#canvas");
let video = $("#video");

let canvasBarrage = new CanvasBarrage(canvas, video, {
    data
});

// 添加播放事件
video.addEventListener("play", function() {
    canvasBarrage.isPaused = false;
    canvasBarrage.render();
});

// 添加暫停事件
video.addEventListener("pause", function() {
    canvasBarrage.isPaused = true;
});
9、實現(xiàn)發(fā)送彈幕事件
// 文件:index.js
$("#add").addEventListener("click", function() {
    let time = video.currentTime; // 發(fā)送彈幕的時間
    let value = $("#text").value; // 發(fā)送彈幕的文字
    let color = $("#color").value; // 發(fā)送彈幕文字的顏色
    let fontSize = $("#range").value; // 發(fā)送彈幕的字體大小
    let sendObj = { time, value, color, fontSize }; //發(fā)送彈幕的參數(shù)集合
    canvasBarrage.add(sendObj); // 發(fā)送彈幕的方法
});

其實我們發(fā)送彈幕時,就是向 CanvasBarrage 類的 barrages 數(shù)組里添加了一條彈幕的實例,我們多帶帶封裝了一個 add 的實例方法。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));

        // Canvas 畫布的內(nèi)容
        this.context = canvas.getContext("2d");

        // 渲染所有的彈幕
        this.render();
    }
    render() {
        // 渲染整個彈幕
        // 第一次先進(jìn)行清空操作,執(zhí)行渲染彈幕,如果沒有暫停,繼續(xù)渲染
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 渲染彈幕
        this.renderBarrage();
        if (this.isPaused == false) {
            // 遞歸渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }
    renderBarrage() {
        // 將數(shù)組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執(zhí)行渲染此彈幕
        let time = this.video.currentTime;
        this.barrages.forEach(barrage => {
            // 當(dāng)視頻時間大于等于了彈幕設(shè)置的時間,那么開始渲染(時間都是以秒為單位)
            if (!barrage.flag && time >= barrage.time) {
                // 初始化彈幕的各個參數(shù),只有在彈幕將要出現(xiàn)的時候再去初始化,節(jié)省性能,初始化后再進(jìn)行繪制
                // 如果沒有初始化,先去初始化一下
                if (!barrage.isInited) {
                    // 初始化后下次再渲染就不需要再初始化了,所以創(chuàng)建一個標(biāo)識 isInited
                    barrage.init();
                    barrage.isInited = true;
                }

                barrage.x -= barrage.speed;
                barrage.render(); // 渲染該條彈幕
                if (barrage.x < barrage.width * -1) {
                    barrage.flag = true; // 是否出去了,出去了,做停止渲染的操作
                }
            }
        });
    }

    // ********** 以下為新增代碼 **********
    add(item) {
        this.barrages.push(new Barrage(item, this));
    }
    // ********** 以上為新增代碼 **********
}
10、拖動進(jìn)度條實現(xiàn)彈幕的前進(jìn)和后退

其實我們發(fā)現(xiàn),彈幕雖然實現(xiàn)了正常的播放、暫停以及發(fā)送,但是當(dāng)我們拖動進(jìn)度條的時候彈幕應(yīng)該是跟著視頻時間同步播放的,現(xiàn)在的彈幕一旦播放過無論怎樣拉動進(jìn)度條彈幕都不會再出現(xiàn),我們現(xiàn)在就來解決這個問題。

// 文件:index.js
// 拖動進(jìn)度條事件
video.addEventListener("seeked", function() {
    canvasBarrage.reset();
});

我們在事件內(nèi)部其實只是調(diào)用了一下 CanvasBarrage 類的 reset 方法,這個方法就是在拖動進(jìn)度條的時候來幫我們初始化彈幕的狀態(tài)。

// 文件:index.js
class CanvasBarrage {
    constructor(canvas, video, options = {}) {
        // 如果沒有傳入 canvas 或者 video 直接跳出
        if (!canvas || !video) return;
        this.canvas = canvas; // 當(dāng)前的 canvas 元素
        this.video = video; // 當(dāng)前的 video 元素

        // 設(shè)置 canvas 與 video 等高
        this.canvas.width = video.clientWidth;
        this.canvas.height = video.clientHeight;

        // 默認(rèn)暫停播放,表示不渲染彈幕
        this.isPaused = true;

        // 沒傳參數(shù)的默認(rèn)值
        let defaultOptions = {
            fontSize: 20,
            color: "gold",
            speed: 2,
            opacity: 0.3,
            data: []
        };

        // 對象的合并,將默認(rèn)參數(shù)對象的屬性和傳入對象的屬性統(tǒng)一放到當(dāng)前實例上
        Object.assign(this, defaultOptions, options);

        // 存放所有彈幕實例,Barrage 是創(chuàng)造每一條彈幕的實例的類
        this.barrages = this.data.map(item => new Barrage(item, this));

        // Canvas 畫布的內(nèi)容
        this.context = canvas.getContext("2d");

        // 渲染所有的彈幕
        this.render();
    }
    render() {
        // 渲染整個彈幕
        // 第一次先進(jìn)行清空操作,執(zhí)行渲染彈幕,如果沒有暫停,繼續(xù)渲染
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 渲染彈幕
        this.renderBarrage();
        if (this.isPaused == false) {
            // 遞歸渲染
            requestAnimationFrame(this.render.bind(this));
        }
    }
    renderBarrage() {
        // 將數(shù)組的彈幕一個一個取出,判斷時間和視頻的時間是否符合,符合就執(zhí)行渲染此彈幕
        let time = this.video.currentTime;
        this.barrages.forEach(barrage => {
            // 當(dāng)視頻時間大于等于了彈幕設(shè)置的時間,那么開始渲染(時間都是以秒為單位)
            if (!barrage.flag && time >= barrage.time) {
                // 初始化彈幕的各個參數(shù),只有在彈幕將要出現(xiàn)的時候再去初始化,節(jié)省性能,初始化后再進(jìn)行繪制
                // 如果沒有初始化,先去初始化一下
                if (!barrage.isInited) {
                    // 初始化后下次再渲染就不需要再初始化了,所以創(chuàng)建一個標(biāo)識 isInited
                    barrage.init();
                    barrage.isInited = true;
                }

                barrage.x -= barrage.speed;
                barrage.render(); // 渲染該條彈幕
                if (barrage.x < barrage.width * -1) {
                    barrage.flag = true; // 是否出去了,出去了,做停止渲染的操作
                }
            }
        });
    }
    add(item) {
        this.barrages.push(new Barrage(item, this));
    }

    // ********** 以下為新增代碼 **********
    reset() {
        // 先清空 Canvas 畫布
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        let time = this.video.currentTime;
        // 循環(huán)每一條彈幕實例
        this.barrages.forEach(barrage => {
            // 更改已經(jīng)移出屏幕的彈幕狀態(tài)
            barrage.flag = false;
            // 當(dāng)拖動到的時間小于等于當(dāng)前彈幕時間是,重新初始化彈幕的數(shù)據(jù),實現(xiàn)渲染
            if (time <= barrage.time) {
                barrage.isInited = false;
            } else {
                barrage.flag = true; // 否則將彈幕的狀態(tài)設(shè)置為以移出屏幕
            }
        });
    }
    // ********** 以上為新增代碼 **********
}

其實 reset 方法中值做了幾件事:

清空 Canvas 畫布;

獲取當(dāng)前進(jìn)度條拖動位置的時間;

循環(huán)存儲彈幕實例的數(shù)組;

將所有彈幕更改為未移出屏幕;

判斷拖動時間和每條彈幕的時間;

在當(dāng)前時間以后的彈幕重新初始化數(shù)據(jù);

以前的彈幕更改為已移出屏幕。

從而實現(xiàn)了拖動進(jìn)度條彈幕的 “前進(jìn)” 和 “后退” 功能。


使用 WebSocket 和 Redis 實現(xiàn)前后端通信及數(shù)據(jù)存儲 1、服務(wù)器代碼的實現(xiàn)

要使用 WebSocket 和 Redis 首先需要去安裝 wsredis 依賴,在項目根目錄執(zhí)行下面命令:

npm install ws redis

我們創(chuàng)建一個 server.js 文件,用來寫服務(wù)端的代碼:

// 文件:index.js
const WebSocket = require("ws"); // 引入 WebSocket
const redis = require("redis"); // 引入 redis

// 初始化 WebSocket 服務(wù)器,端口號為 3000
let wss = new WebSocket.Server({
    port: 3000
});

// 創(chuàng)建 redis 客戶端
let client = redis.createClient(); // key value

// 原生的 websocket 就兩個常用的方法 on("message")、on("send")
wss.on("connection", function(ws) {
    // 監(jiān)聽連接
    // 連接上需要立即把 redis 數(shù)據(jù)庫的數(shù)據(jù)取出返回給前端
    client.lrange("barrages", 0, -1, function(err, applies) {
        // 由于 redis 的數(shù)據(jù)都是字符串,所以需要把數(shù)組中每一項轉(zhuǎn)成對象
        applies = applies.map(item => JSON.parse(item));

        // 使用 websocket 服務(wù)器將 redis 數(shù)據(jù)庫的數(shù)據(jù)發(fā)送給前端
        // 構(gòu)建一個對象,加入 type 屬性告訴前端當(dāng)前返回數(shù)據(jù)的行為,并將數(shù)據(jù)轉(zhuǎn)換成字符串
        ws.send(
            JSON.stringify({
                type: "INIT",
                data: applies
            })
        );
    });

    // 當(dāng)服務(wù)器收到消息時,將數(shù)據(jù)存入 redis 數(shù)據(jù)庫
    ws.on("message", function(data) {
        // 向數(shù)據(jù)庫存儲時存的是字符串,存入并打印數(shù)據(jù),用來判斷是否成功存入數(shù)據(jù)庫
        client.rpush("barrages", data, redis.print);

        // 再將當(dāng)前這條數(shù)據(jù)返回給前端,同樣添加 type 字段告訴前端當(dāng)前行為,并將數(shù)據(jù)轉(zhuǎn)換成字符串
        ws.send(
            JSON.stringify({
                type: "ADD",
                data: JSON.parse(data)
            })
        );
    });
});

服務(wù)器的邏輯很清晰,在 WebSocket 連接上時,立即獲取 Redis 數(shù)據(jù)庫的所有彈幕數(shù)據(jù)返回給前端,當(dāng)前端點(diǎn)擊發(fā)送彈幕按鈕發(fā)送數(shù)據(jù)時,接收數(shù)據(jù)存入 Redis 數(shù)據(jù)庫中并打印驗證數(shù)據(jù)是否成功存入,再通過 WebSocket 服務(wù)把當(dāng)前這一條數(shù)返回給前端,需要注意一下幾點(diǎn):

從 Redis 數(shù)據(jù)庫中取出全部彈幕數(shù)據(jù)的數(shù)組內(nèi)部都存儲的是字符串,需要使用 JSON.parse 方法進(jìn)行解析;

將數(shù)據(jù)發(fā)送前端時,最外層要使用 JSON.stringify 重新轉(zhuǎn)換成字符串發(fā)送;

在初始化階段 WebSocket 發(fā)送所有數(shù)據(jù)和前端添加新彈幕 WebSocket 將彈幕的單條數(shù)據(jù)重新返回時,需要添加對應(yīng)的 type 值告訴前端,當(dāng)前的操作行為。

2、前端代碼的修改

在沒有實現(xiàn)后端代碼之前,前端使用的是 data 的假數(shù)據(jù),是在添加彈幕事件中,將獲取的新增彈幕信息通過 CanvasBarrage 類的 add 方法直接創(chuàng)建 Barrage 類的實例,并加入到存放彈幕實例的 barrages 數(shù)組中。

現(xiàn)在我們需要更正一下交互邏輯,在發(fā)送彈幕事件觸發(fā)時,我們應(yīng)該先將獲取的單條彈幕數(shù)據(jù)通過 WebSocket 發(fā)送給后端服務(wù)器,在服務(wù)器重新將消息返還給我們的時候,去將這條數(shù)據(jù)通過 CanvasBarrage 類的 add 方法加入到存放彈幕實例的 barrages 數(shù)組中。

還有在頁面初始化時,我們之前在創(chuàng)建 CanvasBarrage 類實例的時候直接傳入了 data 假數(shù)據(jù),現(xiàn)在需要通過 WebSocket 的連接事件,在監(jiān)聽到連接 WebSocket 服務(wù)時,去創(chuàng)建 CanvasBarrage 類的實例,并直接把服務(wù)端返回 Redis 數(shù)據(jù)庫真實的數(shù)據(jù)作為參數(shù)傳入,前端代碼修改如下:

// 文件:index.js
// ********** 下面代碼被刪掉了 **********
// let canvasBarrage = new CanvasBarrage(canvas, video, {
//     data
// });
// ********** 上面代碼被刪掉了 **********

// ********** 以下為新增代碼 **********
let canvasBarrage;

// 創(chuàng)建 WebSocket 連接
let socket = new WebSocket("ws://localhost:3000");

// 監(jiān)聽連接事件
socket.onopen = function() {
    // 監(jiān)聽消息
    socket.onmessage = function(e) {
        // 將收到的消息從字符串轉(zhuǎn)換成對象
        let message = JSON.parse(e.data);

        // 根據(jù)不同情況判斷是初始化還是發(fā)送彈幕
        if (message.type === "INIT") {
            // 創(chuàng)建 CanvasBarrage 的實例添加彈幕功能,傳入真實的數(shù)據(jù)
            canvasBarrage = new CanvasBarrage(canvas, video, {
                data: message.data
            });
        } else if (message.type === "ADD") {
            // 如果是添加彈幕直接將 WebSocket 返回的單條彈幕存入 barrages 中
            canvasBarrage.add(message.data);
        }
    };
};
// ********** 以上為新增代碼 **********

$("#add").addEventListener("click", function() {
    let time = video.currentTime; // 發(fā)送彈幕的時間
    let value = $("#text").value; // 發(fā)送彈幕的文字
    let color = $("#color").value; // 發(fā)送彈幕文字的顏色
    let fontSize = $("#range").value; // 發(fā)送彈幕的字體大小
    let sendObj = { time, value, color, fontSize }; //發(fā)送彈幕的參數(shù)集合

    // ********** 以下為新增代碼 **********
    socket.send(JSON.stringify(sendObj));
    // ********** 以上為新增代碼 **********

    // ********** 下面代碼被刪掉了 **********
    // canvasBarrage.add(sendObj); // 發(fā)送彈幕的方法
    // ********** 上面代碼被刪掉了 **********
});

現(xiàn)在我們可以打開 index.html 文件并啟動 server.js 服務(wù)器,就可以實現(xiàn)真實的視頻彈幕操作了,但是我們還是差了最后一步,當(dāng)前的服務(wù)只能同時服務(wù)一個人,但真實的場景是同時看視頻的有很多人,而且發(fā)送的彈幕是共享的。

3、實現(xiàn)多端通信、彈幕共享

我們需要處理兩件事情:

第一件事情是實現(xiàn)多端通信共享數(shù)據(jù)庫信息;

第二件事情是當(dāng)有人離開的時候清除關(guān)閉的 WebSocket 對象。

// 文件:server.js
const WebSocket = require("ws"); // 引入 WebSocket
const redis = require("redis"); // 引入 redis

// 初始化 WebSocket 服務(wù)器,端口號為 3000
let wss = new WebSocket.Server({
    port: 3000
});

// 創(chuàng)建 redis 客戶端
let client = redis.createClient(); // key value

// ********** 以下為新增代碼 **********
// 存儲所有 WebSocket 用戶
let clientsArr = [];
// ********** 以上為新增代碼 **********

// 原生的 websocket 就兩個常用的方法 on("message")、on("send")
wss.on("connection", function(ws) {
    // ********** 以下為新增代碼 **********
    // 將所有通過 WebSocket 連接的用戶存入數(shù)組中
    clientsArr.push(ws);
    // ********** 以上為新增代碼 **********

    // 監(jiān)聽連接
    // 連接上需要立即把 redis 數(shù)據(jù)庫的數(shù)據(jù)取出返回給前端
    client.lrange("barrages", 0, -1, function(err, applies) {
        // 由于 redis 的數(shù)據(jù)都是字符串,所以需要把數(shù)組中每一項轉(zhuǎn)成對象
        applies = applies.map(item => JSON.parse(item));

        // 使用 websocket 服務(wù)器將 redis 數(shù)據(jù)庫的數(shù)據(jù)發(fā)送給前端
        // 構(gòu)建一個對象,加入 type 屬性告訴前端當(dāng)前返回數(shù)據(jù)的行為,并將數(shù)據(jù)轉(zhuǎn)換成字符串
        ws.send(
            JSON.stringify({
                type: "INIT",
                data: applies
            })
        );
    });

    // 當(dāng)服務(wù)器收到消息時,將數(shù)據(jù)存入 redis 數(shù)據(jù)庫
    ws.on("message", function(data) {
        // 向數(shù)據(jù)庫存儲時存的是字符串,存入并打印數(shù)據(jù),用來判斷是否成功存入數(shù)據(jù)庫
        client.rpush("barrages", data, redis.print);

        // ********** 以下為修改后的代碼 **********
        // 循環(huán)數(shù)組,將某一個人新發(fā)送的彈幕在存儲到 Redis 之后返回給所有用戶
        clientsArr.forEach(w => {
            // 再將當(dāng)前這條數(shù)據(jù)返回給前端,同樣添加 type 字段告訴前端當(dāng)前行為,并將數(shù)據(jù)轉(zhuǎn)換成字符串
            w.send(
                JSON.stringify({
                    type: "ADD",
                    data: JSON.parse(data)
                })
            );
        });
        // ********** 以上為修改后的代碼 **********
    });

    // ********** 以下為新增代碼 **********
    // 監(jiān)聽關(guān)閉連接事件
    ws.on("close", function() {
        // 當(dāng)某一個人關(guān)閉連接離開時,將這個人從當(dāng)前存儲用戶的數(shù)組中移除
        clientsArr = clientsArr.filter(client => client != ws);
    });
    // ********** 以上為新增代碼 **********
});

上面就是 Canvas + WebSocket + Redis 視頻彈幕的實現(xiàn),實現(xiàn)過程可能有些復(fù)雜,但整個過程寫的還是比較詳細(xì),可能需要一定的耐心慢慢的讀完,并最好一步一步跟著寫一寫,希望這篇文章可以讓讀到的人解決視頻彈幕類似的需求,真正理解整個過程和開放封閉原則,認(rèn)識到前端面向?qū)ο缶幊趟枷氲拿馈?/p>


文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。

轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/53053.html

相關(guān)文章

  • Canvas + WebSocket + Redis 實現(xiàn)一個視頻彈幕

    摘要:創(chuàng)建彈幕功能的類及基本參數(shù)處理布局時需要注意的默認(rèn)寬為,高為,我們要保證完全覆蓋整個視頻,需要讓與寬高相等。因為我們不確定每一個使用該功能的視頻的寬高都是一樣的,所以畫布的寬高并沒有通過來設(shè)置,而是通過在類創(chuàng)建實例初始化屬性的時候動態(tài)設(shè)置。 showImg(https://segmentfault.com/img/remote/1460000018998386); 閱讀原文 頁面布...

    WelliJhon 評論0 收藏0
  • Canvas + WebSocket + Redis 實現(xiàn)一個視頻彈幕

    摘要:創(chuàng)建彈幕功能的類及基本參數(shù)處理布局時需要注意的默認(rèn)寬為,高為,我們要保證完全覆蓋整個視頻,需要讓與寬高相等。因為我們不確定每一個使用該功能的視頻的寬高都是一樣的,所以畫布的寬高并沒有通過來設(shè)置,而是通過在類創(chuàng)建實例初始化屬性的時候動態(tài)設(shè)置。 showImg(https://segmentfault.com/img/remote/1460000018998386); 閱讀原文 頁面布...

    wangdai 評論0 收藏0
  • 彈幕,是怎樣練成的?

    showImg(https://segmentfault.com/img/bVbk1Nl?w=1080&h=602); 說起彈幕看過視頻的都不會陌生,那滿屏充滿著飄逸評論的效果,讓人如癡如醉,無法自拔 最近也是因為在學(xué)習(xí)關(guān)于 canvas 的知識,所以今天就想和大家分享一個關(guān)于彈幕的故事 那么究竟彈幕是怎樣煉成的呢? 我們且往下看(look) 看什么?看效果 showImg(https://s...

    lwx12525 評論0 收藏0
  • 全棧開發(fā)——動手打造屬于自己的直播間(Vue+SpringBoot+Nginx)

    摘要:經(jīng)過琢磨,其實是要考慮安全性的。具體在以下幾個方面跨域連接協(xié)議升級前握手?jǐn)r截器消息信道攔截器對于跨域問題,我們可以通過方法來設(shè)置可連接的域名,防止跨站連接。 前言 大學(xué)的學(xué)習(xí)時光臨近尾聲,感嘆時光匆匆,三年一晃而過。同學(xué)們都忙著找工作,我也在這里拋一份簡歷吧,歡迎各位老板和獵手誠邀。我們進(jìn)入正題。直播行業(yè)是當(dāng)前火熱的行業(yè),誰都想從中分得一杯羹,直播養(yǎng)活了一大批人,一個平臺主播粗略估計就...

    e10101 評論0 收藏0

發(fā)表評論

0條評論

最新活動
閱讀需要支付1元查看
<