تطبيق ويب تقدّمي (PWA) مع البث بلا إنترنت

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

توفّر تطبيقات الويب التقدّمية الكثير من الميزات التي كانت محصورة سابقًا بالتطبيقات المتوافقة مع الأجهزة فقط على الويب. من أهم الميزات المرتبطة بتطبيقات الويب المتجاوبة هي إمكانية استخدامها بلا اتصال بالإنترنت.

ويمكنك تقديم تجربة أفضل من خلال بث الوسائط بلا اتصال بالإنترنت، وهو ميزة يمكنك تقديمها للمستخدمين بعدة طرق مختلفة. ومع ذلك، يؤدي ذلك إلى حدوث مشكلة فريدة من نوعها، إذ يمكن أن تكون ملفات الوسائط كبيرة جدًا. لذلك، قد تتساءل:

  • كيف يمكنني تنزيل ملف فيديو كبير وتخزينه؟
  • وكيف يمكنني عرضها للمستخدم؟

سنناقش في هذه المقالة إجابات عن هذه الأسئلة، مع الإشارة إلى تطبيق الويب التقدّمي التجريبي Kino الذي أنشأناه والذي يوفّر لك مثالاً عمليًا على كيفية تنفيذ تجربة بث وسائط بلا إنترنت بدون استخدام أي إطارات عمل وظيفية أو عرضية. تهدف الأمثلة التالية أساسًا إلى أغراض تعليمية، لأنّه في معظم الحالات، من المرجّح أن تستخدم أحد إطارات عمل الوسائط الحالية لتوفير هذه الميزات.

إنّ إنشاء تطبيق متوافق مع الأجهزة الجوّالة (PWA) يتضمن تحديات، ما لم يكن لديك دراسة جدوى جيدة لتطوير تطبيقك الخاص. ستتعرّف في هذه المقالة على واجهات برمجة التطبيقات والأساليب المستخدَمة لتقديم تجربة إعلام بلا إنترنت عالية الجودة للمستخدمين.

تنزيل ملف وسائط كبير وتخزينه

تستخدم تطبيقات الويب التقدّميّة عادةً واجهة برمجة التطبيقات Cache API المناسبة لتنزيل مواد العرض المطلوبة لتوفير تجربة بلا إنترنت وتخزينها، مثل المستندات وملفات الأنماط والصور وغيرها.

في ما يلي مثال أساسي لاستخدام واجهة برمجة التطبيقات Cache API في 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 في تطبيقنا التجريبي لتطبيق الويب التقدّمي، الذي أطلقنا عليه اسم 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 تتم بمعدل أقل، ما يؤدي إلى تحسين أداء الكتابة بشكل كبير.

عرض ملف وسائط من مساحة التخزين بلا إنترنت

بعد تنزيل ملف وسائط، قد تحتاج إلى أن يعرضه العامل في الخدمة من 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;

يمكنك الاطّلاع على رمز المصدر لعامل الخدمة في تطبيق PWA التجريبي Kino للتعرّف على كيفية قراءة بيانات الملفات من IndexedDB وإنشاء بث في تطبيق حقيقي.

اعتبارات أخرى

بعد إزالة العقبات الرئيسية، يمكنك الآن البدء في إضافة بعض الميزات المفيدة إلى تطبيق الفيديو. في ما يلي بعض الأمثلة على الميزات التي يمكنك العثور عليها في تطبيق الويب التقدّمي التجريبي Kino:

  • دمج Media Session API الذي يتيح للمستخدمين التحكّم في تشغيل الوسائط باستخدام مفاتيح وسائط الأجهزة المخصّصة أو من مربّعات الوسائط المنبثقة للرسائل.
  • تخزين مواد العرض الأخرى المرتبطة بملفات الوسائط، مثل الترجمة والشرح، وصور الملصقات باستخدام واجهة برمجة التطبيقات Cache API
  • إتاحة تنزيل مجموعات بث الفيديو (DASH وHLS) داخل التطبيق: بما أنّ ملفات بيان البث تُعلن بشكل عام عن مصادر متعددة لمعدّلات بت مختلفة، عليك تحويل ملف البيان وتنزيل نسخة واحدة فقط من الوسائط قبل تخزينها لمشاهدتها بلا إنترنت.

في القسم التالي، ستتعرّف على ميزة التشغيل السريع مع ميزة "التحمّل المُسبَق" للصوت والفيديو.