PWA עם סטרימינג אופליין

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

אפליקציות Progressive Web App מביאות לאינטרנט הרבה תכונות שהיו זמינות רק לאפליקציות מקוריות. אחת מהתכונות הבולטות ביותר שמשויכות לאפליקציות PWA היא חוויית שימוש אופליין.

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

  • איך מורידים ומאחסנים קובץ וידאו גדול?
  • ואיך אפשר להציג אותו למשתמש?

במאמר הזה נספק תשובות לשאלות האלה, תוך התייחסות לאפליקציית ה-PWA לדוגמה Kino שיצרנו, שמספקת דוגמאות מעשיות לאופן שבו אפשר ליישם חוויית מדיה בסטרימינג אופליין בלי להשתמש במסגרות פונקציונליות או פרסומיות. הדוגמאות הבאות הן בעיקר למטרות חינוכיות, כי ברוב המקרים מומלץ להשתמש באחד ממסגרות המדיה הקיימות כדי לספק את התכונות האלה.

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

הורדה ושמירה של קובץ מדיה גדול

אפליקציות אינטרנט מתקדמות בדרך כלל משתמשות ב-Cache API הנוח כדי להוריד ולאחסן את הנכסים הנדרשים כדי לספק את חוויית השימוש אופליין: מסמכים, גיליונות סגנונות, תמונות ועוד.

הנה דוגמה בסיסית לשימוש ב-Cache API בתוך Service Worker:

const cacheStorageName = 'v1';

this.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(cacheStorageName).then(function(cache) {
      return cache.addAll([
        'index.html',
        'style.css',
        'scripts.js',

        // Don't do this.
        'very-large-video.mp4',
      ]);
    })
  );
});

הדוגמה שלמעלה פועלת מבחינה טכנית, אבל ל-Cache API יש כמה מגבלות שמונעות שימוש בו עם קבצים גדולים.

לדוגמה, Cache API לא:

  • להשהות ולהמשיך הורדות בקלות
  • מעקב אחר התקדמות ההורדות
  • מציעים דרך להגיב כראוי לבקשות טווח HTTP

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

כיום, Fetch API הוא דרך חוצת-דפדפנים לגשת באופן אסינכרוני לקבצים מרוחקים. בתרחיש לדוגמה שלנו, האפשרות הזו מאפשרת לגשת לקובצי וידאו גדולים כשידור ולשמור אותם באופן מצטבר כקטעים באמצעות בקשת טווח HTTP.

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

אתם לא שומרים רק את קובץ המדיה, אלא אובייקט מובנה, וקובץ המדיה הוא רק אחד מהמאפיינים שלו.

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

הורדת קובצי מדיה באמצעות Fetch API

יצרנו כמה תכונות מעניינות בנושא Fetch API באפליקציית ה-PWA לדוגמה שלנו, שקראנו לה Kino. קוד המקור הוא ציבורי, אז אתם מוזמנים לבדוק אותו.

  • היכולת להשהות ולהמשיך הורדות חלקיות.
  • מאגר נתונים מותאם אישית לאחסון מקטעי נתונים במסד הנתונים.

לפני שנראה איך מטמיעים את התכונות האלה, נתחיל בסיכום קצר של האופן שבו אפשר להשתמש ב-Fetch API כדי להוריד קבצים.

/**
 * Downloads a single file.
 *
 * @param {string} url URL of the file to be downloaded.
 */
async function downloadFile(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  do {
    const { done, dataChunk } = await reader.read();
    // Store the `dataChunk` to IndexedDB.
  } while (!done);
}

שימו לב ש-await reader.read() נמצא בלולאה? כך תקבלו קטעי נתונים מזרם קריא כשהם מגיעים מהרשת. זהו יתרון משמעותי: אפשר להתחיל לעבד את הנתונים עוד לפני שהם מגיעים מהרשת.

המשך ההורדות

כשהורדה מושהית או מופסקת, קטעי הנתונים שהתקבלו מאוחסנים בצורה מאובטחת במסד נתונים של IndexedDB. לאחר מכן תוכלו להציג באפליקציה לחצן להמשך ההורדה. מכיוון ששרת ה-PWA לדוגמה של Kino תומך בבקשות טווח של HTTP, קל יחסית להמשיך את ההורדה:

async downloadFile() {
  // this.currentFileMeta contains data from IndexedDB.
  const { bytesDownloaded, url, downloadUrl } = this.currentFileMeta;
  const fetchOpts = {};

  // If we already have some data downloaded,
  // request everything from that position on.
  if (bytesDownloaded) {
    fetchOpts.headers = {
      Range: `bytes=${bytesDownloaded}-`,
    };
  }

  const response = await fetch(downloadUrl, fetchOpts);
  const reader = response.body.getReader();

  let dataChunk;
  do {
    dataChunk = await reader.read();
    if (!dataChunk.done) this.buffer.add(dataChunk.value);
  } while (!dataChunk.done && !this.paused);
}

מאגר כתיבה מותאם אישית ל-IndexedDB

על הנייר, תהליך הכתיבה של ערכים מסוג dataChunk למסד נתונים של IndexedDB הוא פשוט. הערכים האלה כבר הם מופעים של ArrayBuffer, שאפשר לאחסן ישירות ב-IndexedDB, כך שאנחנו יכולים פשוט ליצור אובייקט בצורה המתאימה ולאחסן אותו.

const dataItem = {
  url: fileUrl,
  rangeStart: dataStartByte,
  rangeEnd: dataEndByte,
  data: dataChunk,
}

// Name of the store that will hold your data.
const storeName = 'fileChunksStorage'

// `db` is an instance of `IDBDatabase`.
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(data);

putRequest.onsuccess = () => { ... }

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

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

כשקטעי הנתונים מגיעים מהרשת, אנחנו מוסיפים אותם למאגר (buffer) שלנו קודם. אם הנתונים הנכנסים לא מתאימים, אנחנו מנקים את המאגר המלא במסד הנתונים ומנקים אותו לפני שמצרפים את שאר הנתונים. כתוצאה מכך, פעולות הכתיבה ב-IndexedDB הן פחות תכופות, וכך הביצועים של פעולות הכתיבה משתפרים באופן משמעותי.

הצגת קובץ מדיה מאחסון אופליין

אחרי הורדת קובץ מדיה, סביר להניח שתרצו שה-service worker יציג אותו מ-IndexedDB במקום לאחזר את הקובץ מהרשת.

/**
 * The main service worker fetch handler.
 *
 * @param {FetchEvent} event Fetch event.
 */
const fetchHandler = async (event) => {
  const getResponse = async () => {
    // Omitted Cache API code used to serve static assets.

    const videoResponse = await getVideoResponse(event);
    if (videoResponse) return videoResponse;

    // Fallback to network.
    return fetch(event.request);
  };
  event.respondWith(getResponse());
};
self.addEventListener('fetch', fetchHandler);

מה צריך לעשות ב-getVideoResponse()?

  • השיטה event.respondWith() מצפה לאובייקט Response כפרמטר.

  • המבנה (constructor) של Response()‎ מראה שיש כמה סוגים של אובייקטים שאפשר להשתמש בהם כדי ליצור אובייקט Response: Blob,‏ BufferSource,‏ ReadableStream ועוד.

  • אנחנו צריכים אובייקט שלא שומר את כל הנתונים שלו בזיכרון, לכן כדאי לבחור ב-ReadableStream.

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

/**
 * Respond to a request to fetch offline video file and construct a response
 * stream.
 *
 * Includes support for `Range` requests.
 *
 * @param {Request} request  Request object.
 * @param {Object}  fileMeta File meta object.
 *
 * @returns {Response} Response object.
 */
const getVideoResponse = (request, fileMeta) => {
  const rangeRequest = request.headers.get('range') || '';
  const byteRanges = rangeRequest.match(/bytes=(?<from>[0-9]+)?-(?<to>[0-9]+)?/);

  // Using the optional chaining here to access properties of
  // possibly nullish objects.
  const rangeFrom = Number(byteRanges?.groups?.from || 0);
  const rangeTo = Number(byteRanges?.groups?.to || fileMeta.bytesTotal - 1);

  // Omitting implementation for brevity.
  const streamSource = {
     pull(controller) {
       // Read file data here and call `controller.enqueue`
       // with every retrieved chunk, then `controller.close`
       // once all data is read.
     }
  }
  const stream = new ReadableStream(streamSource);

  // Make sure to set proper headers when supporting range requests.
  const responseOpts = {
    status: rangeRequest ? 206 : 200,
    statusText: rangeRequest ? 'Partial Content' : 'OK',
    headers: {
      'Accept-Ranges': 'bytes',
      'Content-Length': rangeTo - rangeFrom + 1,
    },
  };
  if (rangeRequest) {
    responseOpts.headers['Content-Range'] = `bytes ${rangeFrom}-${rangeTo}/${fileMeta.bytesTotal}`;
  }
  const response = new Response(stream, responseOpts);
  return response;

מומלץ לעיין בקוד המקור של ה-service worker של הדמו של Kino ל-PWA כדי לראות איך אנחנו קוראים נתוני קבצים מ-IndexedDB ויוצרים סטרימינג באפליקציה אמיתית.

שיקולים נוספים

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

  • שילוב של Media Session API שמאפשר למשתמשים לשלוט בהפעלת המדיה באמצעות מפתחות מדיה ייעודיים לחומרה או מחלונות קופצים של התראות מדיה.
  • שמירת נכסים אחרים שמשויכים לקובצי המדיה במטמון, כמו כתוביות ותמונות פוסטרים, באמצעות Cache API הישן והטוב.
  • תמיכה בהורדה של שידורי וידאו (DASH,‏ HLS) באפליקציה. בדרך כלל, מניפסט של שידורים מציין כמה מקורות של קצבי סיביות שונים, ולכן צריך לבצע טרנספורמציה של קובץ המניפסט ולהוריד רק גרסה אחת של מדיה לפני שמאחסנים אותו לצפייה אופליין.

בקטע הבא נסביר על הפעלה מהירה עם טעינת אודיו ווידאו מראש.