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

ديريك هيرمان
ديريك هيرمان
ياروسلاف بولاكوفيتش
ياروسلاف بولاكوفيتش

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

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

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

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

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

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

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

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

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',
      ]);
    })
  );
});

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

على سبيل المثال، لا تقوم واجهة برمجة تطبيقات ذاكرة التخزين المؤقت بما يلي:

  • السماح لك بإيقاف عمليات التنزيل مؤقتًا واستئنافها بسهولة
  • تتبُّع مدى تقدّم عمليات التنزيل
  • تقديم طريقة للرد بشكل صحيح على طلبات نطاق HTTP

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

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

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

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

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

تنزيل ملفات الوسائط باستخدام Fetch 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. يمكنك بعد ذلك عرض زر لاستئناف عملية تنزيل في تطبيقك بما أنّ خادم 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);
}

مخزن مؤقت مخصّص للكتابة لقاعدة البيانات المفهرسة

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

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