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

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

توفّر تطبيقات الويب التقدّمية العديد من الميزات التي كانت محجوزة سابقًا للتطبيقات الأصلية على الويب. تُعد التجربة بلا اتصال بالإنترنت من أبرز الميزات المرتبطة بتطبيقات الويب التقدّمية (PWA).

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

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

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

ما لم تكن لديك دراسة جدوى جيدة لإنشاء تطبيق ويب تقدّمي (PWA)، فإن إنشاء تطبيق ويب تقدّمي (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

تشكّل كل هذه المشاكل قيودًا خطيرة جدًا في أي تطبيق فيديو. لنراجع بعض الخيارات الأخرى التي قد تكون أكثر ملاءمة.

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

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

فأنت لا تخزن ملف وسائط واحد فقط، بل كائنًا منظمًا، وملف الوسائط هو خاصية واحدة فقط.

في هذه الحالة، توفر IndexedDB API حلاً ممتازًا لتخزين بيانات الوسائط والبيانات الوصفية. ويمكنها احتواء كميات هائلة من البيانات الثنائية بسهولة، كما توفّر أيضًا فهارس تتيح لك إجراء عمليات بحث عن البيانات بسرعة كبيرة.

تنزيل ملفات الوسائط باستخدام واجهة برمجة تطبيقات الجلب

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

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

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

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

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

ستتعرّف تاليًا على التشغيل السريع باستخدام التحميل المسبق للصوت والفيديو.