تاريخ النشر: 5 تموز (يوليو) 2021
توفّر تطبيقات الويب التقدّمية الكثير من الميزات التي كانت في السابق حصرية للتطبيقات الأصلية على الويب. من أبرز الميزات المرتبطة بتطبيقات الويب التقدّمية إمكانية استخدامها بلا إنترنت.
والأفضل من ذلك هو توفير تجربة بث وسائط بلا إنترنت، وهو تحسين يمكنك تقديمه للمستخدمين بعدة طرق مختلفة. ومع ذلك، يؤدي ذلك إلى مشكلة فريدة من نوعها، وهي أنّ ملفات الوسائط يمكن أن تكون كبيرة جدًا. لذا، قد تتساءل:
- كيف يمكنني تنزيل ملف فيديو كبير وتخزينه؟
- وكيف يمكنني عرضها للمستخدم؟
في هذه المقالة، سنناقش إجابات عن هذه الأسئلة، مع الإشارة إلى تطبيق الويب التقدّمي التجريبي Kino الذي أنشأناه والذي يقدّم لك أمثلة عملية حول كيفية تنفيذ تجربة بث وسائط بلا إنترنت بدون استخدام أي أُطر عمل وظيفية أو عرضية. الأمثلة التالية مخصّصة بشكل أساسي للأغراض التعليمية، لأنّه في معظم الحالات، من المفترض أن تستخدم إحدى أُطر وسائط الحالية لتوفير هذه الميزات.
ما لم يكن لديك دراسة جدوى جيدة لتطوير تطبيقك الخاص، فإنّ إنشاء تطبيق ويب تقدّمي يتيح البث بلا إنترنت ينطوي على بعض التحديات. ستتعرّف في هذه المقالة على واجهات برمجة التطبيقات والتقنيات المستخدَمة لتوفير تجربة وسائط عالية الجودة بلا إنترنت للمستخدمين.
تنزيل ملف وسائط كبير وتخزينه
تستخدم تطبيقات الويب التقدّمية عادةً واجهة برمجة التطبيقات Cache API المريحة لتنزيل وتخزين مواد العرض المطلوبة لتوفير تجربة بلا إنترنت، مثل المستندات وأوراق الأنماط والصور وغيرها.
في ما يلي مثال أساسي على استخدام 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 يتضمّن العديد من القيود التي تجعل استخدامه مع الملفات الكبيرة غير عملي.
على سبيل المثال، لا تنفّذ 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. يمكنك بعد ذلك عرض زر لاستئناف عملية التنزيل في تطبيقك. بما أنّ خادم 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;
يمكنك الاطّلاع على Kino، وهو تطبيق ويب تقدّمي تجريبي، وشفرة المصدر الخاصة ببرنامج عامل الخدمة لمعرفة كيفية قراءة بيانات الملفات من IndexedDB وإنشاء بث في تطبيق حقيقي.
اعتبارات أخرى
بعد إزالة العقبات الرئيسية، يمكنك الآن البدء في إضافة بعض الميزات التي قد تكون مفيدة لتطبيق الفيديو. في ما يلي بعض الأمثلة على الميزات التي يمكنك العثور عليها في تطبيق الويب التوضيحي Kino:
- دمج Media Session API الذي يتيح للمستخدمين التحكّم في تشغيل الوسائط باستخدام مفاتيح وسائط مخصّصة على الأجهزة أو من النوافذ المنبثقة لإشعارات الوسائط
- تخزين الأصول الأخرى المرتبطة بملفات الوسائط مؤقتًا، مثل الترجمة والصور الترويجية، باستخدام Cache API القديمة
- إتاحة تنزيل بث الفيديو (DASH وHLS) داخل التطبيق، لأنّ بيانات وصف البث تتضمّن عادةً مصادر متعددة بمعدلات بت مختلفة، لذا عليك تحويل ملف بيانات الوصف وتنزيل نسخة وسائط واحدة فقط قبل تخزينها لمشاهدتها بلا إنترنت
في القسم التالي، سنتحدّث عن التشغيل السريع مع التحميل المُسبَق للصوت والفيديو.