PWA با پخش آفلاین

درک هرمان
Derek Herman
یاروسلاو پولاکوویچ
Jaroslav Polakovič

منتشر شده: ۵ ژوئیه ۲۰۲۱

برنامه‌های وب پیش‌رونده (PWA) بسیاری از ویژگی‌هایی را که قبلاً برای برنامه‌های بومی در نظر گرفته شده بود، به وب می‌آورند. یکی از برجسته‌ترین ویژگی‌های مرتبط با PWAها، تجربه آفلاین است.

حتی بهتر از آن، یک تجربه پخش آنلاین رسانه است که می‌توانید به روش‌های مختلف به کاربران خود ارائه دهید. با این حال، این یک مشکل واقعاً منحصر به فرد ایجاد می‌کند - فایل‌های رسانه‌ای می‌توانند بسیار بزرگ باشند. بنابراین ممکن است بپرسید:

  • چگونه یک فایل ویدیویی حجیم را دانلود و ذخیره کنم؟
  • و چطور باید اون رو به کاربر نشون بدم؟

در این مقاله، ضمن اشاره به PWA آزمایشی Kino که ساخته‌ایم، به پاسخ این سؤالات خواهیم پرداخت. این PWA مثال‌های عملی از چگونگی پیاده‌سازی یک تجربه پخش رسانه‌ای آفلاین بدون استفاده از هیچ چارچوب عملکردی یا نمایشی را در اختیار شما قرار می‌دهد. مثال‌های زیر عمدتاً برای اهداف آموزشی هستند، زیرا در بیشتر موارد، احتمالاً باید از یکی از چارچوب‌های رسانه‌ای موجود برای ارائه این ویژگی‌ها استفاده کنید.

مگر اینکه طرح توجیهی خوبی برای توسعه‌ی PWA خود داشته باشید، ساخت یک PWA با پخش آفلاین چالش‌های خودش را دارد. در این مقاله با APIها و تکنیک‌هایی که برای ارائه یک تجربه رسانه‌ای آفلاین با کیفیت بالا به کاربران استفاده می‌شوند، آشنا خواهید شد.

دانلود و ذخیره یک فایل رسانه‌ای حجیم

برنامه‌های وب پیش‌رونده معمولاً از رابط برنامه‌نویسی کاربردی (API) Cache برای دانلود و ذخیره دارایی‌های مورد نیاز برای ارائه تجربه آفلاین استفاده می‌کنند: اسناد، شیوه‌نامه‌ها، تصاویر و موارد دیگر.

در اینجا یک مثال ساده از استفاده از 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 مربوط به حافظه پنهان (Cache API) موارد زیر را انجام نمی‌دهد:

  • به شما امکان می‌دهد دانلودها را به راحتی متوقف کرده و از سر بگیرید
  • به شما امکان می‌دهد پیشرفت دانلودها را پیگیری کنید
  • ارائه روشی برای پاسخ صحیح به درخواست‌های محدوده HTTP

همه این مشکلات، محدودیت‌های جدی برای هر برنامه ویدیویی هستند. بیایید چند گزینه دیگر را که ممکن است مناسب‌تر باشند، بررسی کنیم.

امروزه، Fetch API یک روش چند مرورگری برای دسترسی غیرهمزمان به فایل‌های راه دور است. در مورد استفاده ما، این API به شما امکان می‌دهد به فایل‌های ویدیویی بزرگ به صورت یک جریان دسترسی پیدا کنید و آنها را به صورت تدریجی و به صورت تکه‌هایی با استفاده از یک درخواست محدوده HTTP ذخیره کنید.

حالا که می‌توانید تکه‌های داده را با Fetch API بخوانید، باید آنها را ذخیره کنید. احتمالاً مجموعه‌ای از فراداده‌ها (metadata) با فایل رسانه شما مرتبط هستند، مانند: نام، توضیحات، مدت زمان اجرا، دسته و غیره.

شما فقط یک فایل رسانه‌ای را ذخیره نمی‌کنید، بلکه یک شیء ساختاریافته را ذخیره می‌کنید و فایل رسانه‌ای فقط یکی از ویژگی‌های آن است.

در این مورد، API مربوط به IndexedDB یک راه‌حل عالی برای ذخیره داده‌های رسانه‌ای و فراداده‌ها ارائه می‌دهد. این API می‌تواند حجم عظیمی از داده‌های دودویی را به راحتی در خود نگه دارد و همچنین ایندکس‌هایی را ارائه می‌دهد که به شما امکان می‌دهد جستجوی داده‌ها را بسیار سریع انجام دهید.

دانلود فایل‌های رسانه‌ای با استفاده از Fetch API

ما در PWA نسخه آزمایشی خود، که آن را Kino نامگذاری کردیم، چند ویژگی جالب در مورد Fetch API ایجاد کردیم - کد منبع عمومی است، بنابراین می‌توانید آن را بررسی کنید.

  • قابلیت مکث و از سرگیری دانلودهای ناقص.
  • یک بافر سفارشی برای ذخیره تکه‌های داده در پایگاه داده.

قبل از اینکه نحوه پیاده‌سازی این ویژگی‌ها را نشان دهیم، ابتدا خلاصه‌ای سریع از نحوه استفاده از 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 PWA ما این کار را با پیاده‌سازی یک بافر نوشتن واسطه انجام می‌دهیم.

وقتی تکه‌های داده از شبکه می‌رسند، ابتدا آنها را به بافر خود اضافه می‌کنیم. اگر داده‌های ورودی جا نشوند، کل بافر را در پایگاه داده خالی می‌کنیم و قبل از اضافه کردن بقیه داده‌ها، آن را پاک می‌کنیم. در نتیجه، نوشتن‌های IndexedDB ما کمتر اتفاق می‌افتد که منجر به بهبود قابل توجه عملکرد نوشتن می‌شود.

ارائه یک فایل رسانه‌ای از فضای ذخیره‌سازی آفلاین

وقتی یک فایل رسانه‌ای دانلود شد، احتمالاً می‌خواهید سرویس ورکر شما به جای دریافت فایل از شبکه، آن را از 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;

برای آشنایی با نحوه خواندن داده‌های فایل از IndexedDB و ساخت یک جریان در یک برنامه واقعی، می‌توانید کد منبع سرویس ورکر PWA نسخه آزمایشی Kino را بررسی کنید.

ملاحظات دیگر

با کنار رفتن موانع اصلی، اکنون می‌توانید شروع به اضافه کردن برخی ویژگی‌های خوب به برنامه ویدیویی خود کنید. در اینجا چند نمونه از ویژگی‌هایی که در PWA نسخه آزمایشی Kino پیدا خواهید کرد، آورده شده است:

  • یکپارچه‌سازی API جلسه رسانه که به کاربران شما اجازه می‌دهد پخش رسانه را با استفاده از کلیدهای رسانه‌ای سخت‌افزاری اختصاصی یا از طریق پنجره‌های اعلان رسانه‌ای کنترل کنند.
  • ذخیره سازی سایر دارایی‌های مرتبط با فایل‌های رسانه‌ای مانند زیرنویس‌ها و تصاویر پوستر با استفاده از رابط برنامه‌نویسی کاربردی قدیمی Cache .
  • پشتیبانی از دانلود استریم‌های ویدیویی (DASH، HLS) درون برنامه. از آنجا که مانیفست‌های استریم معمولاً منابع متعددی با بیت‌ریت‌های مختلف را اعلام می‌کنند، باید فایل مانیفست را تغییر دهید و قبل از ذخیره آن برای مشاهده آفلاین، فقط یک نسخه رسانه‌ای را دانلود کنید.

در ادامه، درباره پخش سریع با پیش‌بارگذاری صدا و تصویر خواهید آموخت.