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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

פורסם: 5 ביולי 2021

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

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

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

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

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

הורדה ואחסון של קובץ מדיה גדול

בדרך כלל, אפליקציות Progressive Web Apps משתמשות ב-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, אנחנו עושים את זה באמצעות הטמעה של מאגר כתיבה ביניים.

כשנתוני המקטעים מגיעים מהרשת, אנחנו מוסיפים אותם למאגר שלנו. אם הנתונים הנכנסים לא מתאימים, אנחנו מרוקנים את המאגר המלא למסד הנתונים ומנקים אותו לפני שמצרפים את שאר הנתונים. כתוצאה מכך, הכתיבה שלנו ב-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 כפרמטר.

  • הבונה 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;

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

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

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

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

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