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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

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

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

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

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

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

אפליקציות מסוג Progressive Web App משתמשות בדרך כלל ב-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 ויוצרים זרם יישום אמיתי.

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

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

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

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