用 Sencha Ext JS 建立应用

该文档的目的是使您初步了解如何使用 Sencha Ext JS 框架建立 Chrome 应用。要实现这一目的,我们将会深入讨论一个通过 Sencha 建立的媒体播放器应用,源代码API 文档可以在在 GitHub 上访问。

该应用程序搜索用户可用的媒体服务器,包括连接到 PC 的媒体设备及通过网络管理媒体的软件。用户可以浏览媒体、通过网络播放或者离线保存。

如下是您使用 Sencha Ext JS 建立媒体播放器应用时必须做的几个关键步骤:

  • 创建清单文件 manifest.json
  • 创建事件页面 background.js
  • 沙盒屏蔽应用的逻辑。
  • 在 Chrome 应用与经过沙盒屏蔽的文件之间通信。
  • 搜索媒体服务器。
  • 浏览与播放媒体。
  • 离线保存媒体。

创建清单文件

所有 Chrome 应用都需要一个清单文件,包含 Chrome 浏览器执行应用时需要的信息。如清单文件中所述,媒体播放器应用是 "offline_enabled"(在离线状态下也能使用),媒体资源可以在本地保存、访问及播放,不管有没有网络连接。

"sandbox" 字段用来将应用的主要逻辑通过沙盒屏蔽在一个唯一的来源中,所有经过沙盒屏蔽的内容不受 Chrome 应用内容安全策略的限制,但也不能直接访问 Chrome 应用的 API。清单文件还包含了 "socket" 权限,因为媒体播放器应用使用套接字 API 通过网络连接到媒体服务器。

{
    "name": "Video Player",
    "description": "Features network media discovery and playlist management",
    "version": "1.0.0",
    "manifest_version": 2,
    "offline_enabled": true,
    "app": {
        "background": {
            "scripts": [
                "background.js"
            ]
        }
    },
    ...

    "sandbox": {
        "pages": ["sandbox.html"]
    },
    "permissions": [
        "experimental",
        "http://*/*",
        "unlimitedStorage",
        {
            "socket": [
                "tcp-connect",
                "udp-send-to",
                "udp-bind"
            ]
        }
    ]
}

创建事件页面

所有 Chrome 应用都需要 background.js 来执行应用。媒体播放器的主页面 index.html 将会在指定大小的窗口中打开:

chrome.app.runtime.onLaunched.addListener(function(launchData) {
    var opt = {
        width: 1000,
        height: 700
    };

    chrome.app.window.create('index.html', opt, function (win) {
        win.launchData = launchData;
    });

});

通过沙盒屏蔽应用的逻辑

Chrome 应用程序在一个受控制的环境中运行,强制实施严格的内容安全策略(CSP),而媒体播放器应用需要某些更高的权限来渲染 Ext JS 组件。为了遵循 CSP 的同时执行应用的逻辑,应用的主页面 index.html 创建了一个 iframe,作为一个经过沙盒屏蔽的环境:

<iframe id="sandbox-frame" sandbox="allow-scripts" src="sandbox.html"></iframe>

iframe 指向 sandbox.html,包含了 Ext JS 应用需要的文件:

<html>
<head>
    <link rel="stylesheet" type="text/css" href="resources/css/app.css" />
    <script src="sdk/ext-all-dev.js"></script>
    <script src="lib/ext/data/PostMessage.js"></script>
    <script src="lib/ChromeProxy.js"></script>
    <script src="app.js"></script>
</head>
<body></body>
</html>

app.js 脚本执行所有 Ext JS 代码,并渲染媒体播放器的视图。由于该脚本经过沙盒屏蔽,它无法直接访问 Chrome 应用的 API,但可以使用 HTML5 消息传递 API 实现 app.js 与不经过沙盒屏蔽的文件间的通信。

在文件之间通信

为了使媒体播放应用访问 Chrome 应用的 API,例如查询网络上的媒体服务器,app.jsindex.js 传递消息。和经过沙盒屏蔽的 app.js 不同,index.js 可以直接访问 Chrome 应用 API。

index.js 获取框架:

var iframe = document.getElementById('sandbox-frame');

iframeWindow = iframe.contentWindow;

然后监听来自经过沙盒屏蔽的文件的消息:

window.addEventListener('message', function(e) {
    var data= e.data,
        key = data.key;

    console.log('[index.js] Post Message received with key ' + key);

    switch (key) {
        case 'extension-baseurl':
            extensionBaseUrl(data);
            break;

        case 'upnp-discover':
            upnpDiscover(data);
            break;

        case 'upnp-browse':
            upnpBrowse(data);
            break;

        case 'play-media':
            playMedia(data);
            break;

        case 'download-media':
            downloadMedia(data);
            break;

        case 'cancel-download':
            cancelDownload(data);
            break;

        default:
            console.log('[index.js] unidentified key for Post Message: "' + key + '"');
    }
}, false);

在以下例子中,app.jsindex.js 发送消息,请求键为 'extension-baseurl':

Ext.data.PostMessage.request({
    key: 'extension-baseurl',
    success: function(data) {
        //...
    }
});

index.js 接收请求,设置结果,通过发回基本 URL 的方式回复:

function extensionBaseUrl(data) {
    data.result = chrome.extension.getURL('/');
    iframeWindow.postMessage(data, '*');
}

搜索媒体服务器

搜索媒体服务器涉及到许多细节。从较高的层次来看,搜索的工作流程由用户搜索可用媒体服务器的操作发起,媒体服务器控制器index.js 发送消息,index.js 监听该消息,收到时调用 Upnp.js

Upnp 库使用 Chrome 应用的套接字 API 将所有发现的媒体服务器连接到媒体播放器应用,并从媒体服务器接收媒体数据。Upnp.js 还使用 soapclient.js 分析媒体服务器的数据。这一节剩下的内容更详细地描述这一工作流程。

传递消息

当用户单击媒体播放器应用中央的媒体服务器按钮时,MediaServers 调用 discoverServers()。该函数首先检查是否有正在进行的发现请求,如果有的话终止它们以便发起新的请求。接着,控制器向 index.js 传递消息,键为 upnp-discovery,并包含两个回调函数监听器:

me.activeDiscoverRequest = Ext.data.PostMessage.request({
    key: 'upnp-discover',
    success: function(data) {
        var items = [];
        delete me.activeDiscoverRequest;

        if (serversGraph.isDestroyed) {
            return;
        }

        mainBtn.isLoading = false;
        mainBtn.removeCls('pop-in');
        mainBtn.setIconCls('ico-server');
        mainBtn.setText('Media Servers');

        //add servers
        Ext.each(data, function(server) {
            var icon,
                urlBase = server.urlBase;

            if (urlBase) {
                if (urlBase.substr(urlBase.length-1, 1) === '/'){
                        urlBase = urlBase.substr(0, urlBase.length-1);
                }
            }

            if (server.icons && server.icons.length) {
                if (server.icons[1]) {
                    icon = server.icons[1].url;
                }
                else {
                    icon = server.icons[0].url;
                }

                icon = urlBase + icon;
            }

            items.push({
                itemId: server.id,
                text: server.friendlyName,
                icon: icon,
                data: server
            });
        });

        ...
    },
    failure: function() {
        delete me.activeDiscoverRequest;
                
        if (serversGraph.isDestroyed) {
            return;
        }
                
        mainBtn.isLoading = false;
        mainBtn.removeCls('pop-in');
        mainBtn.setIconCls('ico-error');
        mainBtn.setText('Error...click to retry');
    }
});

调用 upnpDiscover()

index.js 监听来自 app.js 的 'upnp-discover' 消息,并通过调用 upnpDiscover() 响应。当发现某个媒体服务器时,index.js 从参数中提取媒体服务器的域名,在本地保存服务器,格式化媒体服务器数据,并将数据传递给 MediaServer 控制器。

分析媒体服务器数据

Upnp.js 发现新的媒体服务器时,它会获取设备的描述,并发出 Soaprequest 浏览并分析媒体服务器数据,soapclient.js 通过文档中的标签名称分析媒体元素。

连接到媒体服务器

Upnp.js 连接到发现的媒体服务器,并使用 Chrome 应用的套接字 API 接收媒体数据:

socket.create("udp", {}, function(info) {
    var socketId = info.socketId;
        
    //bind locally
    socket.bind(socketId, "0.0.0.0", 0, function(info) {
            
        //pack upnp message
        var message = String.toBuffer(UPNP_MESSAGE);
            
        //broadcast to upnp
        socket.sendTo(socketId, message, UPNP_ADDRESS, UPNP_PORT, function(info) {
                
            // Wait 1 second
            setTimeout(function() {
                    
                //receive
                socket.recvFrom(socketId, function(info) {

                    //unpack message
                    var data        = String.fromBuffer(info.data),
                        servers     = [],
                        locationReg = /^location:/i;
                            
                    //extract location info
                    if (data) {
                        data = data.split("\r\n");
                            
                        data.forEach(function(value) {
                            if (locationReg.test(value)){
                                servers.push(value.replace(locationReg, "").trim());
                            }
                        });
                    }
                            
                    //success
                    callback(servers);
                });
                    
            }, 1000);
        });
    });
});

浏览和播放媒体

MediaExplorer 控制器列出媒体服务器文件夹中的所有媒体文件,并负责更新媒体播放器应用窗口中的面包屑导航。当用户选中媒体文件时,控制器向 index.js 传递消息,键为 'play-media'。

onFileDblClick: function(explorer, record) {
    var serverPanel, node,
        type    = record.get('type'),
        url     = record.get('url'),
        name    = record.get('name'),
        serverId= record.get('serverId');
            
    if (type === 'audio' || type === 'video') {
        Ext.data.PostMessage.request({
            key     : 'play-media',
            params  : {
                url: url,
                name: name,
                type: type
            }
        });
    }
},

index.js 监听传递过来的该消息,并通过调用 playMedia() 响应。

function playMedia(data) {
    var type        = data.params.type,
        url         = data.params.url,
        playerCt    = document.getElementById('player-ct'),
        audioBody   = document.getElementById('audio-body'),
        videoBody   = document.getElementById('video-body'),
        mediaEl     = playerCt.getElementsByTagName(type)[0],
        mediaBody   = type === 'video' ? videoBody : audioBody,
        isLocal     = false;

    //save data
    filePlaying = {
        url : url,
        type: type,
        name: data.params.name
    };

    //hide body els
    audioBody.style.display = 'none';
    videoBody.style.display = 'none';

    var animEnd = function(e) {

        //show body el
        mediaBody.style.display = '';

        //play media
        mediaEl.play();

        //clear listeners
        playerCt.removeEventListener( 'webkitTransitionEnd', animEnd, false );
        animEnd = null;
    };

    //load media
    mediaEl.src = url;
    mediaEl.load();

    //animate in player
    playerCt.addEventListener( 'webkitTransitionEnd', animEnd, false );
    playerCt.style.webkitTransform = "translateY(0)";

    //reply postmessage
    data.result = true;
    sendMessage(data);
}

离线保存媒体

离线保存媒体的大部分繁重任务由 filer.js 来实现,您可以在 filer.js 简介中了解有关该库的更多内容。

当用户选择一个或多个文件,并执行 'Take offline'(离线保存)操作时,将开始这一过程。MediaExplorer 控制器index.js 传递消息,键为 'download-media'。index.js 监听该消息并调用 downloadMedia() 函数开始下载过程:

function downloadMedia(data) {
        DownloadProcess.run(data.params.files, function() {
            data.result = true;
            sendMessage(data);
        });
    }

DownloadProcess 实用方法创建 XHR 请求从媒体服务器获取数据,并等待完成状态。然后调用 onload 回调函数,检查接收到的内容并使用 filer.js 函数在本地保存数据:

filer.write(
    saveUrl,
    {
        data: Util.arrayBufferToBlob(fileArrayBuf),
        type: contentType
    },
    function(fileEntry, fileWriter) {

        console.log('file saved!');

        //increment downloaded
        me.completedFiles++;

        //if reached the end, finalize the process
        if (me.completedFiles === me.totalFiles) {

            sendMessage({
                key             : 'download-progresss',
                totalFiles      : me.totalFiles,
                completedFiles  : me.completedFiles
            });

            me.completedFiles = me.totalFiles = me.percentage = me.downloadedFiles = 0;
            delete me.percentages;

            //reload local
            loadLocalFiles(callback);
        }
    },
    function(e) {
        console.log(e);
    }
);

下载过程完成后,MediaExplorer 更新媒体文件列表以及媒体播放器的树窗格。