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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

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

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

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

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

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

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

في ما يلي مثال أساسي لاستخدام واجهة برمجة تطبيقات ذاكرة التخزين المؤقت داخل مشغّل الخدمات:

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. رمز المصدر متاح للجميع، لذا يمكنك مراجعته.

  • إمكانية إيقاف عمليات التنزيل غير المكتملة مؤقتًا واستئنافها
  • مخزن مؤقت مخصص لتخزين مجموعات من البيانات في قاعدة البيانات.

قبل عرض كيفية تنفيذ هذه الميزات، سنتناول أولاً ملخصًا سريعًا لكيفية استخدام واجهة برمجة تطبيقات الجلب لتنزيل الملفات.

/**
 * 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 تتمّ بمعدل أقل، ما يؤدي إلى تحسين أداء عملية الكتابة بشكل كبير.

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

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

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