PWA dengan streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

Progressive Web App menghadirkan banyak fitur yang sebelumnya disediakan untuk native aplikasi ke web. Salah satu fitur yang paling menonjol yang terkait dengan PWA adalah pengalaman offline.

Pengalaman yang lebih baik lagi adalah pengalaman media {i>streaming<i} secara {i>offline<i}, yang merupakan tambahan yang dapat Anda tawarkan kepada pengguna dalam beberapa cara yang berbeda. Namun, hal ini akan menimbulkan masalah yang benar-benar unik—file media bisa menjadi sangat besar. Namun mungkin Anda bertanya:

  • Bagaimana cara mendownload dan menyimpan file video besar?
  • Dan bagaimana cara menyajikannya kepada pengguna?

Dalam artikel ini kita akan membahas jawaban atas pertanyaan-pertanyaan tersebut, sementara merujuk ke PWA demo Kino yang kami buat yang memberi Anda pengalaman contoh cara menerapkan pengalaman media streaming offline tanpa menggunakan kerangka kerja fungsional atau presentasi. Contoh berikut adalah terutama untuk tujuan pendidikan, karena dalam banyak kasus, Anda mungkin harus menggunakan salah satu Framework Media yang ada untuk menyediakan fitur ini.

Kecuali Anda memiliki kasus bisnis yang bagus untuk mengembangkan milik Anda sendiri, membangun PWA dengan streaming offline memiliki tantangannya sendiri. Dalam artikel ini, Anda akan mempelajari API dan teknik yang digunakan untuk menyediakan media offline berkualitas tinggi kepada pengguna pengalaman yang lancar bagi developer.

Mengunduh dan menyimpan file media berukuran besar

Progressive Web App biasanya menggunakan Cache API yang mudah digunakan untuk mendownload dan simpan aset yang diperlukan untuk memberikan pengalaman offline: dokumen, stylesheet, gambar, dan lain-lain.

Berikut adalah contoh dasar penggunaan Cache API dalam 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',
      ]);
    })
  );
});

Meskipun contoh di atas secara teknis berfungsi, penggunaan Cache API memiliki beberapa keterbatasan yang membuat penggunaannya dengan file besar menjadi tidak praktis.

Misalnya, Cache API tidak:

  • Memungkinkan Anda menjeda dan melanjutkan download dengan mudah
  • Memungkinkan Anda memantau progres download
  • Menawarkan cara untuk merespons permintaan rentang HTTP dengan benar

Semua masalah ini adalah batasan yang cukup serius untuk aplikasi video apa pun. Mari kita tinjau beberapa opsi lain yang mungkin lebih sesuai.

Saat ini, Fetch API adalah cara lintas browser untuk mengakses data jarak jauh secara asinkron . Dalam kasus penggunaan kami, {i>router<i} memungkinkan Anda untuk mengakses {i>file<i} video besar sebagai aliran dan menyimpannya secara bertahap sebagai potongan menggunakan permintaan rentang HTTP.

Setelah dapat membaca potongan data dengan Fetch API, Anda juga harus menyimpannya. Kemungkinan ada banyak metadata yang terkait dengan media Anda file seperti: nama, deskripsi, panjang runtime, kategori, dll.

Anda tidak hanya menyimpan satu file media, Anda menyimpan objek terstruktur, dan file media hanyalah salah satu dari propertinya.

Dalam hal ini TensorFlow API memberikan solusi yang sangat baik untuk menyimpan kedua metadata dan data media. Ia dapat menyimpan sejumlah besar data biner dengan mudah, dan juga menawarkan indeks yang memungkinkan Anda melakukan pencarian data dengan sangat cepat.

Mendownload file media menggunakan Fetch API

Kami membuat beberapa fitur menarik di sekitar Fetch API dalam PWA demo kami, yang kami beri nama Kinokode sumber bersifat publik, jadi jangan ragu untuk meninjaunya.

  • Kemampuan untuk menjeda dan melanjutkan download yang belum selesai.
  • Buffer kustom untuk menyimpan potongan data dalam database.

Sebelum menunjukkan bagaimana fitur tersebut diterapkan, pertama-tama kita akan melakukan rangkuman singkat tentang bagaimana Anda dapat menggunakan Fetch API untuk mengunduh file.

/**
 * 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);
}

Perhatikan bahwa await reader.read() berada dalam loop? Begitulah cara Anda akan menerima potongan data dari aliran yang dapat dibaca saat mereka tiba dari jaringan. Pertimbangkan caranya berguna adalah: Anda dapat mulai memproses data bahkan sebelum data tersebut tiba dari jaringan.

Melanjutkan download

Ketika unduhan dijeda atau terganggu, potongan data yang telah sampai akan disimpan dengan aman dalam database IndexedDB. Anda kemudian dapat menampilkan tombol untuk melanjutkan download di aplikasi Anda. Karena server PWA demo Kino mendukung permintaan rentang HTTP sehingga melanjutkan download agak mudah:

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);
}

Buffer tulis kustom untuk chromeos

Di atas kertas, proses penulisan nilai dataChunk ke dalam database IndexedDB sederhana. Nilai tersebut sudah merupakan instance ArrayBuffer, yang dapat disimpan di IndexedDB secara langsung, sehingga kita cukup membuat objek dengan bentuk yang sesuai dan menyimpannya.

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 = () => { ... }

Meskipun pendekatan ini berhasil, Anda mungkin akan menemukan bahwa IndexedDB Anda menulis jauh lebih lambat dibandingkan download Anda. Hal ini tidak terjadi karena IndexedDB menulis lambat, itu karena kita menambahkan banyak {i> overhead<i} transaksional dengan membuat transaksi baru untuk setiap potongan data yang kita terima dari jaringan.

Potongan yang diunduh bisa berukuran agak kecil dan dapat dimunculkan oleh {i>stream<i} dalam suksesi yang cepat. Anda perlu membatasi tingkat penulisan IndexedDB. Di kolom PWA demo Kino kami melakukannya dengan menerapkan buffer penulisan perantara.

Saat potongan data tiba dari jaringan, kita menambahkannya ke {i>buffer<i} terlebih dahulu. Jika data yang masuk tidak muat, kita membuang {i>buffer<i} penuh ke dalam {i>database<i} dan membersihkannya sebelum menambahkan sisa data. Sebagai hasilnya, IndexedDB kami operasi tulis menjadi lebih jarang, sehingga peningkatan operasi tulis secara signifikan tingkat tinggi.

Menyajikan file media dari penyimpanan offline

Setelah Anda memiliki file media yang diunduh, Anda mungkin ingin pekerja layanan Anda untuk menyajikannya dari Responden alih-alih mengambil file dari jaringan.

/**
 * 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);

Jadi, apa yang perlu Anda lakukan di getVideoResponse()?

  • Metode event.respondWith() mengharapkan objek Response sebagai parameter.

  • KonstruktorResponse() memberi tahu kita bahwa ada beberapa jenis objek yang dapat digunakan untuk membuat instance objek Response: Blob, BufferSource, ReadableStream, dan lainnya.

  • Kita membutuhkan objek yang tidak menyimpan semua datanya di memori, jadi kita mungkin ingin memilih ReadableStream.

Juga, karena kita berurusan dengan file besar, dan kita ingin memungkinkan {i>browser<i} untuk hanya meminta sebagian dari file yang saat ini mereka butuhkan, kita perlu menerapkan beberapa dukungan dasar untuk permintaan rentang 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;

Jangan ragu untuk memeriksa kode sumber pekerja layanan PWA Kino untuk menemukan bagaimana kita membaca data file dari IndexedDB dan membuat aliran data di aplikasi yang sebenarnya.

Pertimbangan lainnya

Setelah menyelesaikan kendala utama, sekarang Anda dapat mulai menambahkan beberapa fitur yang bagus untuk dimiliki pada aplikasi video Anda. Berikut beberapa contoh fitur yang akan Anda temukan di PWA demo Kino:

  • Integrasi Media Session API yang memungkinkan pengguna Anda mengontrol media pemutaran menggunakan tombol media hardware khusus atau dari notifikasi media pop-up.
  • Menyimpan data ke dalam cache aset lain yang terkait dengan file media seperti subtitel, dan gambar poster menggunakan Cache API lama yang baik.
  • Dukungan untuk download streaming video (DASH, HLS) dalam aplikasi. Karena streaming manifes umumnya mendeklarasikan beberapa sumber dengan kecepatan bit yang berbeda, Anda harus mengubah file manifes dan hanya mengunduh satu versi media sebelum menyimpan untuk dilihat secara offline.

Selanjutnya, Anda akan mempelajari Pemutaran cepat dengan pramuat audio dan video.