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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

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

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

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

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

הורדת קובצי מדיה באמצעות ממשק ה-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. בהדגמה של 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()?

  • ה-method 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;

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

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

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

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

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