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

דרק הרמן
דרק הרמן
ירוסלב פולקוביץ'
ירוסלב פולקוביץ'

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

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

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

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

לדוגמה, ממשק ה-API של המטמון לא:

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

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

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

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

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

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

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

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

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

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

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

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

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

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