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

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

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

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

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