בניית אפליקציות עם Sancha Ext JS

המטרה של המסמך הזה היא לעזור לכם להתחיל בבניית אפליקציות Chrome באמצעות המסגרת של Sencha Ext JS. כדי להשיג את המטרה הזו, נתעמק באפליקציה של נגן מדיה שפותחה על ידי סנצ'ה. קוד המקור ומסמכי התיעוד של ה-API זמינים ב-GitHub.

האפליקציה מגלה את שרתי המדיה הזמינים למשתמש, כולל מכשירי מדיה שמחוברים למחשב ותוכנה שמנהלת מדיה ברשת. המשתמשים יכולים לדפדף במדיה, להפעיל אותם דרך הרשת או לשמור קבצים במצב אופליין.

אלה הדברים העיקריים שצריך לעשות כדי לבנות אפליקציה של נגן מדיה באמצעות Sancha Ext JS:

  • יצירת מניפסט, manifest.json.
  • יצירת דף האירוע, background.js.
  • הלוגיקה של אפליקציית Sandbox.
  • תקשורת בין אפליקציית Chrome לבין קבצים בארגז חול.
  • גילוי שרתי מדיה.
  • חיפוש והפעלה של מדיה.
  • שמירת מדיה במצב אופליין.

יצירת מניפסט

לכל אפליקציות Chrome נדרש קובץ מניפסט שמכיל את המידע ש-Chrome צריך כדי להפעיל אפליקציות. כפי שמצוין במניפסט, אפליקציית המדיה של Google TV מופעלת באופן לא מקוון. ניתן לשמור נכסי מדיה באופן מקומי, לגשת אליהם ולהפעיל אותם ללא קשר לקישוריות.

השדה 'ארגז חול' משמש לארגז חול ללוגיקה הראשית של האפליקציה במקור ייחודי. כל התוכן שנמצא בארגז החול פטור ממדיניות אבטחת התוכן של אפליקציית Chrome, אבל לא יכול לגשת ישירות לממשקי ה-API של אפליקציות Chrome. המניפסט כולל גם את ההרשאה 'socket'. אפליקציית Media Player משתמשת ב-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;
    });

});

הלוגיקה של אפליקציית Sandbox

אפליקציות Chrome פועלות בסביבה מבוקרת אוכפת מדיניות Content Security Policy (CSP) מחמירה. לאפליקציית נגן המדיה נדרשות הרשאות גבוהות יותר כדי לעבד את רכיבי Ext JS. כדי לעמוד ב-CSP ולהפעיל את הלוגיקה של האפליקציה, בדף הראשי של האפליקציה, index.html, נוצר iframe שפועל כסביבת Sandbox:

<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 ומעבד את התצוגות של Media Player. הסקריפט הזה נמצא בארגז חול, ולכן הוא לא יכול לגשת ישירות לממשקי ה-API של אפליקציות Chrome. התקשורת בין app.js לבין קבצים שאינם בארגז חול מתבצעת באמצעות HTML5 Post Message API.

תקשורת בין קבצים

כדי שאפליקציית נגן המדיה תקבל גישה לממשקי API של אפליקציות Chrome, למשל שליחת שאילתות לרשת לגבי שרתי מדיה, app.js מפרסם הודעות ב-index.js. בניגוד ל-app.js שנעשית בארגז החול, ל-index.js יש גישה ישירה לממשקי ה-API של אפליקציות Chrome.

index.js יוצר את ה-iframe:

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.js שולח הודעה אל index.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, '*');
}

גילוי שרתי מדיה

גילוי שרתי מדיה דורש הרבה עבודה. ברמה הכללית, תהליך הגילוי מופעל על ידי פעולת משתמש לחיפוש שרתי מדיה זמינים. בקר ה-MediaServer שולח הודעה אל index.js; index.js מאזין להודעה הזו ולאחר מכן קורא ל-Upnp.js.

ב-Upnp library נעשה שימוש ב-socket API של אפליקציית Chrome כדי לחבר את האפליקציה של נגן המדיה לכל שרת מדיה שהתגלה ולקבל נתוני מדיה משרת המדיה. Upnp.js משתמש גם ב-soapclient.js כדי לנתח את הנתונים של שרת המדיה. בהמשך החלק הזה מתואר תהליך העבודה בפירוט.

פרסום ההודעה

כשמשתמש לוחץ על הלחצן 'שרתי מדיה' במרכז האפליקציה של נגן המדיה, MediaServers.js מבצע קריאה ל-discoverServers(). קודם כול, הפונקציה הזו בודקת אם יש בקשות גילוי נאות שעדיין לא טופלו, ואם הן תואמות, היא מבטלת אותן כדי שניתן יהיה להתחיל את הבקשה החדשה. בשלב הבא, הבקר שולח הודעה אל index.js עם מפתח upnp-discovery ועם שני מאזינים של קריאה חוזרת (callback):

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 ומגיבה באמצעות הטלפון upnpDiscover(). כשמגלים שרת מדיה, index.js מחלץ את הדומיין של שרת המדיה מהפרמטרים, שומר את השרת באופן מקומי, מעצב את נתוני שרת המדיה ודוחף את הנתונים לבקר של MediaServer.

נתח נתונים משרת המדיה

כש-Upnp.js מאתר שרת מדיה חדש, הוא מאחזר את התיאור של המכשיר ושולח בקשת Soaprequest כדי לעיין בנתונים של שרת המדיה ולנתח אותם. soapclient.js מנתח במסמך את רכיבי המדיה לפי שם התג.

התחברות לשרת המדיה

Upnp.js מתחבר לשרתי מדיה שזוהו ומקבל נתוני מדיה באמצעות ממשק ה-API של שקע אפליקציה של Chrome:

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( 'transitionend', animEnd, false );
        animEnd = null;
    };

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

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

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

שמירת המדיה במצב אופליין

רוב העבודה הקשה לשמירה של מדיה במצב אופליין מתבצעת על ידי ספריית filer.js. אפשר לקרוא פרטים נוספים על הספרייה הזו במאמר היכרות עם filer.js.

התהליך מתחיל כשמשתמש בוחר קובץ אחד או יותר ומבצע את הפעולה 'העברה למצב אופליין'. בקר ה-MediaExplorer מפרסם הודעה אל index.js עם המפתח 'download-media'. index.js מאזין להודעה הזו וקורא לפונקציה downloadMedia() כדי להפעיל את תהליך ההורדה:

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

שיטת השירות DownloadProcess יוצרת בקשת xhr כדי לקבל נתונים משרת המדיה, וממתינה לסטטוס ההשלמה. הפעולה הזו מפעילה את הקריאה החוזרת (callback) כשמתבצעת טעינה, שבודקת את התוכן שהתקבל ושומרת את הנתונים באופן מקומי באמצעות הפונקציה 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 מעדכנים את רשימת קובצי המדיה ואת חלונית העץ של נגן המדיה.