diff --git a/packages/p2p-media-loader-demo/src/components/P2PVideoDemo.tsx b/packages/p2p-media-loader-demo/src/components/P2PVideoDemo.tsx index 44a75057..98c97d47 100644 --- a/packages/p2p-media-loader-demo/src/components/P2PVideoDemo.tsx +++ b/packages/p2p-media-loader-demo/src/components/P2PVideoDemo.tsx @@ -20,6 +20,7 @@ import { ShakaPlyr } from "./players/shaka/ShakaPlyr"; import { HlsJsP2PEngine } from "p2p-media-loader-hlsjs"; import { HlsjsVidstack } from "./players/hlsjs/HlsjsVidstack"; import { PeerDetails } from "p2p-media-loader-core"; +import { HlsjsVidstackIndexedDB } from "./players/hlsjs/HlsjsVidstackIndexedDB"; type DemoProps = { streamUrl?: string; @@ -46,6 +47,7 @@ const playerComponents = { clappr_hls: HlsjsClapprPlayer, dplayer_hls: HlsjsDPlayer, hlsjs_hls: HlsjsPlayer, + vidstack_indexeddb_hls: HlsjsVidstackIndexedDB, shaka: Shaka, dplayer_shaka: ShakaDPlayer, clappr_shaka: ShakaClappr, diff --git a/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.tsx b/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.tsx index c9376c46..4109182e 100644 --- a/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.tsx +++ b/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.tsx @@ -1,4 +1,4 @@ -import "./Hlsjs.css"; +import "./hlsjs.css"; import { useEffect, useRef, useState } from "react"; import { PlayerProps } from "../../../types"; import { subscribeToUiEvents } from "../utils"; diff --git a/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsVidstackIndexedDB.tsx b/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsVidstackIndexedDB.tsx new file mode 100644 index 00000000..88923d32 --- /dev/null +++ b/packages/p2p-media-loader-demo/src/components/players/hlsjs/HlsjsVidstackIndexedDB.tsx @@ -0,0 +1,97 @@ +import "./vidstack_indexed_db.css"; +import "@vidstack/react/player/styles/default/theme.css"; +import "@vidstack/react/player/styles/default/layouts/video.css"; +import { + MediaPlayer, + MediaProvider, + isHLSProvider, + type MediaProviderAdapter, +} from "@vidstack/react"; +import { + defaultLayoutIcons, + DefaultVideoLayout, +} from "@vidstack/react/player/layouts/default"; +import { PlayerProps } from "../../../types"; +import { HlsJsP2PEngine, HlsWithP2PConfig } from "p2p-media-loader-hlsjs"; +import { subscribeToUiEvents } from "../utils"; +import { useCallback } from "react"; +import Hls from "hls.js"; +import { IndexedDbStorage } from "../../../custom-segment-storage-example/indexed-db-storage"; + +export const HlsjsVidstackIndexedDB = ({ + streamUrl, + announceTrackers, + onPeerConnect, + onPeerClose, + onChunkDownloaded, + onChunkUploaded, +}: PlayerProps) => { + const onProviderChange = useCallback( + (provider: MediaProviderAdapter | null) => { + if (isHLSProvider(provider)) { + const HlsWithP2P = HlsJsP2PEngine.injectMixin(Hls); + + provider.library = HlsWithP2P as unknown as typeof Hls; + + const storageFactory = (_isLive: boolean) => new IndexedDbStorage(); + + const config: HlsWithP2PConfig = { + p2p: { + core: { + announceTrackers, + customSegmentStorageFactory: storageFactory, + }, + onHlsJsCreated: (hls) => { + subscribeToUiEvents({ + engine: hls.p2pEngine, + onPeerConnect, + onPeerClose, + onChunkDownloaded, + onChunkUploaded, + }); + }, + }, + }; + + provider.config = config; + } + }, + [ + announceTrackers, + onChunkDownloaded, + onChunkUploaded, + onPeerConnect, + onPeerClose, + ], + ); + + return ( +
+ + + + + +
+

+ Note: Clearing of stored video segments is not + implemented in this example. To remove cached segments, please clear + your browser's IndexedDB manually.{" "} + + View Source Code + +

+
+
+ ); +}; diff --git a/packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.css b/packages/p2p-media-loader-demo/src/components/players/hlsjs/hlsjs.css similarity index 100% rename from packages/p2p-media-loader-demo/src/components/players/hlsjs/Hlsjs.css rename to packages/p2p-media-loader-demo/src/components/players/hlsjs/hlsjs.css diff --git a/packages/p2p-media-loader-demo/src/components/players/hlsjs/vidstack_indexed_db.css b/packages/p2p-media-loader-demo/src/components/players/hlsjs/vidstack_indexed_db.css new file mode 100644 index 00000000..9c2057c5 --- /dev/null +++ b/packages/p2p-media-loader-demo/src/components/players/hlsjs/vidstack_indexed_db.css @@ -0,0 +1,22 @@ +.notice { + margin-top: 10px; + padding: 10px; + border: 1px solid #f0ad4e; + background-color: #fcf8e3; + border-radius: 4px; +} + +.notice p { + margin: 0; + color: #8a6d3b; +} + +.source-code-link { + color: #0275d8; + text-decoration: none; + font-weight: bold; +} + +.source-code-link:hover { + text-decoration: underline; +} diff --git a/packages/p2p-media-loader-demo/src/constants.ts b/packages/p2p-media-loader-demo/src/constants.ts index 48f02c55..1088ac68 100644 --- a/packages/p2p-media-loader-demo/src/constants.ts +++ b/packages/p2p-media-loader-demo/src/constants.ts @@ -8,6 +8,7 @@ export const PLAYERS = { plyr_hls: "Plyr", openPlayer_hls: "OpenPlayerJS", mediaElement_hls: "MediaElement", + vidstack_indexeddb_hls: "Vidstack IndexedDB example", shaka: "Shaka", dplayer_shaka: "DPlayer", clappr_shaka: "Clappr (DASH only)", diff --git a/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-storage.ts b/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-storage.ts new file mode 100644 index 00000000..68d771e5 --- /dev/null +++ b/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-storage.ts @@ -0,0 +1,238 @@ +import { + CommonCoreConfig, + SegmentStorage, + StreamConfig, + StreamType, +} from "p2p-media-loader-core"; +import { IndexedDbWrapper } from "./indexed-db-wrapper"; + +type SegmentDataItem = { + storageId: string; + data: ArrayBuffer; +}; + +type Playback = { + position: number; + rate: number; +}; + +type LastRequestedSegmentInfo = { + streamId: string; + segmentId: number; + startTime: number; + endTime: number; + swarmId: string; + streamType: StreamType; + isLiveStream: boolean; +}; + +type SegmentInfoItem = { + storageId: string; + dataLength: number; + streamId: string; + segmentId: number; + streamType: string; + startTime: number; + endTime: number; + swarmId: string; +}; + +function getStorageItemId(streamId: string, segmentId: number) { + return `${streamId}|${segmentId}`; +} + +const INFO_ITEMS_STORE_NAME = "segmentInfo"; +const DATA_ITEMS_STORE_NAME = "segmentData"; +const DB_NAME = "p2p-media-loader"; +const DB_VERSION = 1; +const BYTES_PER_MB = 1048576; + +export class IndexedDbStorage implements SegmentStorage { + private segmentsMemoryStorageLimit = 4000; // 4 GB + private currentMemoryStorageSize = 0; // current memory storage size in MB + + private storageConfig?: CommonCoreConfig; + private mainStreamConfig?: StreamConfig; + private secondaryStreamConfig?: StreamConfig; + private cache = new Map(); + + private currentPlayback?: Playback; // current playback position and rate + private lastRequestedSegment?: LastRequestedSegmentInfo; // details about the last requested segment by the player + private db: IndexedDbWrapper; + + private segmentChangeCallback?: (streamId: string) => void; + + constructor() { + this.db = new IndexedDbWrapper( + DB_NAME, + DB_VERSION, + INFO_ITEMS_STORE_NAME, + DATA_ITEMS_STORE_NAME, + ); + } + + onPlaybackUpdated(position: number, rate: number) { + this.currentPlayback = { position, rate }; + } + + onSegmentRequested( + swarmId: string, + streamId: string, + segmentId: number, + startTime: number, + endTime: number, + streamType: StreamType, + isLiveStream: boolean, + ) { + this.lastRequestedSegment = { + streamId, + segmentId, + startTime, + endTime, + swarmId, + streamType, + isLiveStream, + }; + } + + async initialize( + storageConfig: CommonCoreConfig, + mainStreamConfig: StreamConfig, + secondaryStreamConfig: StreamConfig, + ) { + this.storageConfig = storageConfig; + this.mainStreamConfig = mainStreamConfig; + this.secondaryStreamConfig = secondaryStreamConfig; + + try { + // await this.db.deleteDatabase(); + await this.db.openDatabase(); + await this.loadCacheMap(); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to initialize custom segment storage:", error); + throw error; + } + } + + async storeSegment( + swarmId: string, + streamId: string, + segmentId: number, + data: ArrayBuffer, + startTime: number, + endTime: number, + streamType: StreamType, + _isLiveStream: boolean, + ) { + const storageId = getStorageItemId(streamId, segmentId); + const segmentDataItem = { + storageId, + data, + }; + const segmentInfoItem = { + storageId, + dataLength: data.byteLength, + streamId, + segmentId, + streamType, + startTime, + endTime, + swarmId, + }; + + try { + /* + * await this.clear(); + * Implement your own logic to remove old segments and manage the memory storage size + */ + + await Promise.all([ + this.db.put(DATA_ITEMS_STORE_NAME, segmentDataItem), + this.db.put(INFO_ITEMS_STORE_NAME, segmentInfoItem), + ]); + + this.cache.set(storageId, segmentInfoItem); + this.increaseMemoryStorageSize(data.byteLength); + + if (this.segmentChangeCallback) { + this.segmentChangeCallback(streamId); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to store segment ${segmentId}:`, error); + throw error; + // Optionally, implement retry logic or other error recovery mechanisms + } + } + + async getSegmentData(_swarmId: string, streamId: string, segmentId: number) { + const segmentStorageId = getStorageItemId(streamId, segmentId); + try { + const result = await this.db.get( + DATA_ITEMS_STORE_NAME, + segmentStorageId, + ); + + return result?.data; + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error retrieving segment data for ${segmentStorageId}:`, + error, + ); + return undefined; + } + } + + getUsage() { + /* + * Implement your own logic to calculate the memory used by the segments stored in memory. + */ + return { + totalCapacity: this.segmentsMemoryStorageLimit, + usedCapacity: this.currentMemoryStorageSize, + }; + } + + hasSegment(_swarmId: string, streamId: string, segmentId: number) { + const storageId = getStorageItemId(streamId, segmentId); + return this.cache.has(storageId); + } + + getStoredSegmentIds(streamId: string) { + const storedSegments: number[] = []; + + for (const segment of this.cache.values()) { + if (segment.streamId === streamId) { + storedSegments.push(segment.segmentId); + } + } + + return storedSegments; + } + + destroy() { + this.db.closeDatabase(); + this.cache.clear(); + } + + setSegmentChangeCallback(callback: (streamId: string) => void) { + this.segmentChangeCallback = callback; + } + + private async loadCacheMap() { + const result = await this.db.getAll(INFO_ITEMS_STORE_NAME); + + result.forEach((item) => { + const storageId = getStorageItemId(item.streamId, item.segmentId); + this.cache.set(storageId, item); + + this.increaseMemoryStorageSize(item.dataLength); + }); + } + + private increaseMemoryStorageSize(dataLength: number) { + this.currentMemoryStorageSize += dataLength / BYTES_PER_MB; + } +} diff --git a/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-wrapper.ts b/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-wrapper.ts new file mode 100644 index 00000000..c384e3af --- /dev/null +++ b/packages/p2p-media-loader-demo/src/custom-segment-storage-example/indexed-db-wrapper.ts @@ -0,0 +1,95 @@ +export class IndexedDbWrapper { + private db: IDBDatabase | null = null; + + constructor( + private readonly dbName: string, + private readonly dbVersion: number, + private readonly infoItemsStoreName: string, + private readonly dataItemsStoreName: string, + ) {} + + async openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onerror = () => reject(new Error("Failed to open database.")); + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + request.onupgradeneeded = (event) => { + this.db = (event.target as IDBOpenDBRequest).result; + this.createObjectStores(this.db); + }; + }); + } + + private createObjectStores(db: IDBDatabase): void { + if (!db.objectStoreNames.contains(this.dataItemsStoreName)) { + db.createObjectStore(this.dataItemsStoreName, { keyPath: "storageId" }); + } + if (!db.objectStoreNames.contains(this.infoItemsStoreName)) { + db.createObjectStore(this.infoItemsStoreName, { keyPath: "storageId" }); + } + } + + async getAll(storeName: string): Promise { + return this.performTransaction(storeName, "readonly", (store) => + store.getAll(), + ); + } + + async put(storeName: string, item: T): Promise { + await this.performTransaction(storeName, "readwrite", (store) => + store.put(item), + ); + } + + async get(storeName: string, key: IDBValidKey): Promise { + return this.performTransaction(storeName, "readonly", (store) => + store.get(key), + ); + } + + async delete(storeName: string, key: IDBValidKey): Promise { + await this.performTransaction(storeName, "readwrite", (store) => + store.delete(key), + ); + } + + private async performTransaction( + storeName: string, + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest, + ): Promise { + return new Promise((resolve, reject) => { + if (!this.db) throw new Error("Database not initialized"); + + const transaction = this.db.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + const request = operation(store); + + request.onerror = () => reject(new Error("IndexedDB operation failed")); + + request.onsuccess = () => { + const result = request.result as T; + resolve(result); + }; + }); + } + + closeDatabase(): void { + if (!this.db) return; + this.db.close(); + this.db = null; + } + + async deleteDatabase(): Promise { + this.closeDatabase(); + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(this.dbName); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error("Failed to delete database.")); + }); + } +}