PWA dengan streaming offline

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

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

Yang lebih baik lagi adalah pengalaman media streaming offline, yang merupakan peningkatan yang dapat Anda tawarkan kepada pengguna dengan beberapa cara. Namun, hal ini menimbulkan masalah yang benar-benar unik—file media dapat berukuran sangat besar. Jadi, Anda mungkin bertanya:

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

Dalam artikel ini, kami akan membahas jawaban atas pertanyaan-pertanyaan tersebut, sekaligus mereferensikan PWA demo Kino yang kami buat yang memberikan contoh praktis tentang cara menerapkan pengalaman media streaming offline tanpa menggunakan framework fungsional atau presentasi apa pun. Contoh berikut secara khusus untuk tujuan pendidikan, karena dalam sebagian besar kasus, Anda mungkin harus menggunakan salah satu Framework Media yang ada untuk menyediakan fitur ini.

Kecuali jika Anda memiliki kasus bisnis yang baik untuk mengembangkan PWA sendiri, membuat PWA dengan streaming offline memiliki tantangannya. Dalam artikel ini, Anda akan mempelajari API dan teknik yang digunakan untuk memberikan pengalaman media offline berkualitas tinggi kepada pengguna.

Mendownload dan menyimpan file media berukuran besar

Aplikasi Web Progresif biasanya menggunakan Cache API yang praktis untuk mendownload dan menyimpan aset yang diperlukan untuk memberikan pengalaman offline: dokumen, stylesheet, gambar, dan lainnya.

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 batasan yang membuat penggunaannya dengan file besar tidak praktis.

Misalnya, Cache API tidak:

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

Semua masalah ini merupakan 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 file jarak jauh secara asinkron. Dalam kasus penggunaan kami, hal ini memungkinkan Anda mengakses file video berukuran besar sebagai streaming dan menyimpan file tersebut secara bertahap sebagai bagian menggunakan permintaan rentang HTTP.

Setelah dapat membaca potongan data dengan Fetch API, Anda juga perlu menyimpan potongan data tersebut. Kemungkinan ada banyak metadata yang terkait dengan file media Anda seperti: nama, deskripsi, durasi runtime, kategori, dll.

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

Dalam hal ini, IndexedDB API memberikan solusi yang sangat baik untuk menyimpan data media dan metadata. Objek ini dapat menyimpan data biner dalam jumlah besar 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 seputar Fetch API di PWA demo kami, yang kami beri nama Kinokode sumber bersifat publik, jadi jangan ragu untuk meninjaunya.

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

Sebelum menunjukkan cara penerapan fitur tersebut, pertama-tama kita akan melakukan ringkasan singkat tentang cara menggunakan Fetch API untuk mendownload 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? Dengan begitu, Anda akan menerima potongan data dari streaming yang dapat dibaca saat data tersebut tiba dari jaringan. Pertimbangkan betapa bergunanya hal ini: Anda dapat mulai memproses data bahkan sebelum semuanya tiba dari jaringan.

Melanjutkan download

Saat download dijeda atau terganggu, potongan data yang telah diterima akan disimpan dengan aman di database IndexedDB. Kemudian, Anda dapat menampilkan tombol untuk melanjutkan download di aplikasi. Karena server PWA demo Kino mendukung permintaan rentang HTTP, melanjutkan download menjadi 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 IndexedDB

Secara teori, proses penulisan nilai dataChunk ke dalam database IndexedDB sangat 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 penulisan IndexedDB Anda secara signifikan lebih lambat daripada download. Hal ini bukan karena penulisan IndexedDB lambat, tetapi karena kita menambahkan banyak overhead transaksional dengan membuat transaksi baru untuk setiap bagian data yang kita terima dari jaringan.

Potongan yang didownload dapat berukuran agak kecil dan dapat dikeluarkan oleh streaming secara berurutan dengan cepat. Anda perlu membatasi kecepatan penulisan IndexedDB. Dalam PWA demo Kino, kita melakukannya dengan menerapkan buffer tulis perantara.

Saat potongan data tiba dari jaringan, kita akan menambahkannya ke buffering terlebih dahulu. Jika data yang masuk tidak sesuai, kita akan mengosongkan buffering penuh ke dalam database dan menghapusnya sebelum menambahkan sisa data. Akibatnya, operasi tulis IndexedDB kami menjadi lebih jarang, yang menyebabkan performa tulis yang meningkat secara signifikan.

Menyajikan file media dari penyimpanan offline

Setelah mendownload file media, Anda mungkin ingin service worker menayangkannya dari IndexedDB, bukan 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.

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

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

Selain itu, karena kita menangani file berukuran besar, dan ingin mengizinkan browser hanya meminta bagian 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 melihat kode sumber pekerja layanan PWA demo Kino untuk mengetahui cara kita membaca data file dari IndexedDB dan membuat streaming di aplikasi sebenarnya.

Pertimbangan lainnya

Setelah mengatasi hambatan utama, Anda kini dapat mulai menambahkan beberapa fitur yang bagus untuk aplikasi video Anda. Berikut beberapa contoh fitur yang akan Anda temukan di PWA demo Kino:

  • Integrasi Media Session API yang memungkinkan pengguna mengontrol pemutaran media menggunakan tombol media hardware khusus atau dari pop-up pemberitahuan media.
  • Penyimpanan dalam cache aset lain yang terkait dengan file media seperti subtitel, dan gambar poster menggunakan Cache API lama yang bagus.
  • Dukungan untuk download streaming video (DASH, HLS) dalam aplikasi. Karena manifes streaming umumnya mendeklarasikan beberapa sumber dengan kecepatan bit yang berbeda, Anda perlu mengubah file manifes dan hanya mendownload satu versi media sebelum menyimpannya untuk ditonton secara offline.

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