@lightbird/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var assCompiler = require('ass-compiler');
4
- var ffmpeg = require('@ffmpeg/ffmpeg');
5
- var util = require('@ffmpeg/util');
4
+ var webSdk = require('@openfeature/web-sdk');
5
+ var unleashWebProvider = require('@openfeature/unleash-web-provider');
6
6
 
7
7
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
8
8
  // src/config.ts
@@ -1104,6 +1104,132 @@ function parseM3U8(text) {
1104
1104
  return items;
1105
1105
  }
1106
1106
 
1107
+ // src/magnet-player.ts
1108
+ var VIDEO_EXTENSIONS = [
1109
+ "mp4",
1110
+ "mkv",
1111
+ "avi",
1112
+ "mov",
1113
+ "wmv",
1114
+ "flv",
1115
+ "webm",
1116
+ "m4v",
1117
+ "ts",
1118
+ "m2ts",
1119
+ "ogv",
1120
+ "ogg",
1121
+ "divx",
1122
+ "xvid",
1123
+ "rmvb",
1124
+ "rm"
1125
+ ];
1126
+ var DEFAULT_TRACKERS = [
1127
+ "wss://tracker.openwebtorrent.com",
1128
+ "wss://tracker.btorrent.xyz",
1129
+ "wss://tracker.fastcast.nz"
1130
+ ];
1131
+ var DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
1132
+ function isMagnetUri(str) {
1133
+ if (typeof str !== "string" || !str.trim()) return false;
1134
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
1135
+ }
1136
+ function isVideoFile(name) {
1137
+ if (typeof name !== "string" || !name) return false;
1138
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
1139
+ return VIDEO_EXTENSIONS.includes(ext);
1140
+ }
1141
+ function getVideoFiles(torrent) {
1142
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
1143
+ }
1144
+ function hasAcceptedDisclaimer() {
1145
+ try {
1146
+ return localStorage.getItem(DISCLAIMER_KEY) === "true";
1147
+ } catch {
1148
+ return false;
1149
+ }
1150
+ }
1151
+ function acceptDisclaimer() {
1152
+ try {
1153
+ localStorage.setItem(DISCLAIMER_KEY, "true");
1154
+ } catch {
1155
+ }
1156
+ }
1157
+ var wtClient = null;
1158
+ var wtClientPromise = null;
1159
+ var swPromise = null;
1160
+ async function ensureServiceWorker() {
1161
+ if (swPromise) return swPromise;
1162
+ swPromise = (async () => {
1163
+ if (!("serviceWorker" in navigator)) {
1164
+ throw new Error("Service workers are not supported in this browser");
1165
+ }
1166
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
1167
+ if (existing?.active) return existing;
1168
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
1169
+ scope: "/"
1170
+ });
1171
+ if (reg.active) return reg;
1172
+ return new Promise((resolve, reject) => {
1173
+ const sw = reg.installing ?? reg.waiting;
1174
+ if (!sw) {
1175
+ if (reg.active) return resolve(reg);
1176
+ return reject(new Error("No service worker found after registration"));
1177
+ }
1178
+ const onStateChange = () => {
1179
+ if (sw.state === "activated") {
1180
+ sw.removeEventListener("statechange", onStateChange);
1181
+ resolve(reg);
1182
+ } else if (sw.state === "redundant") {
1183
+ sw.removeEventListener("statechange", onStateChange);
1184
+ reject(new Error("Service worker became redundant during activation"));
1185
+ }
1186
+ };
1187
+ sw.addEventListener("statechange", onStateChange);
1188
+ });
1189
+ })();
1190
+ return swPromise;
1191
+ }
1192
+ async function getWebTorrentClient() {
1193
+ if (wtClient && !wtClient.destroyed) return wtClient;
1194
+ if (wtClientPromise) return wtClientPromise;
1195
+ wtClientPromise = (async () => {
1196
+ const { default: WebTorrentClass } = await import('webtorrent');
1197
+ const client = new WebTorrentClass();
1198
+ wtClient = client;
1199
+ try {
1200
+ const registration = await ensureServiceWorker();
1201
+ if (!client._server) {
1202
+ client.createServer({ controller: registration });
1203
+ }
1204
+ } catch (err) {
1205
+ console.warn("[magnet-player] Service worker setup failed:", err);
1206
+ }
1207
+ return client;
1208
+ })();
1209
+ return wtClientPromise;
1210
+ }
1211
+ function destroyWebTorrentClient() {
1212
+ if (wtClient && !wtClient.destroyed) {
1213
+ wtClient.destroy();
1214
+ }
1215
+ wtClient = null;
1216
+ wtClientPromise = null;
1217
+ }
1218
+ var FLAG_MAGNET_LINK = "magnet-link-enabled";
1219
+ function initFeatureFlags() {
1220
+ const url = process.env.NEXT_PUBLIC_UNLEASH_URL ?? "";
1221
+ const clientKey = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY ?? "";
1222
+ if (!url || !clientKey) {
1223
+ console.warn(
1224
+ "[feature-flags] NEXT_PUBLIC_UNLEASH_URL and/or NEXT_PUBLIC_UNLEASH_CLIENT_KEY are not set \u2014 feature flags will use their default values (magnet link enabled by default)."
1225
+ );
1226
+ return Promise.resolve();
1227
+ }
1228
+ return webSdk.OpenFeature.setProviderAndWait(
1229
+ new unleashWebProvider.UnleashWebProvider({ url, clientKey, appName: "lightbird" })
1230
+ );
1231
+ }
1232
+
1107
1233
  // src/utils/media-error.ts
1108
1234
  var MEDIA_ERR_ABORTED = 1;
1109
1235
  var MEDIA_ERR_NETWORK = 2;
@@ -1305,6 +1431,8 @@ var ProgressEstimator = class {
1305
1431
  }
1306
1432
  // No reset() method — create a new instance per file load instead.
1307
1433
  };
1434
+
1435
+ // src/utils/ffmpeg-singleton.ts
1308
1436
  var instance = null;
1309
1437
  var loading = null;
1310
1438
  var defaultCDN = "https://unpkg.com/@ffmpeg/core@0.12.10/dist/umd";
@@ -1312,14 +1440,18 @@ async function getFFmpeg() {
1312
1440
  if (instance) return instance;
1313
1441
  if (loading) return loading;
1314
1442
  loading = (async () => {
1315
- const ffmpeg$1 = new ffmpeg.FFmpeg();
1443
+ const [{ FFmpeg }, { toBlobURL }] = await Promise.all([
1444
+ import('@ffmpeg/ffmpeg'),
1445
+ import('@ffmpeg/util')
1446
+ ]);
1447
+ const ffmpeg = new FFmpeg();
1316
1448
  const baseURL = getConfig().ffmpegCDN || defaultCDN;
1317
- await ffmpeg$1.load({
1318
- coreURL: await util.toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
1319
- wasmURL: await util.toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
1449
+ await ffmpeg.load({
1450
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
1451
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
1320
1452
  });
1321
- instance = ffmpeg$1;
1322
- return ffmpeg$1;
1453
+ instance = ffmpeg;
1454
+ return ffmpeg;
1323
1455
  })();
1324
1456
  return loading;
1325
1457
  }
@@ -1331,21 +1463,33 @@ function resetFFmpeg() {
1331
1463
  exports.ASSRenderer = ASSRenderer;
1332
1464
  exports.CancellationError = CancellationError;
1333
1465
  exports.DEFAULT_SHORTCUTS = DEFAULT_SHORTCUTS;
1466
+ exports.DEFAULT_TRACKERS = DEFAULT_TRACKERS;
1467
+ exports.DISCLAIMER_KEY = DISCLAIMER_KEY;
1468
+ exports.FLAG_MAGNET_LINK = FLAG_MAGNET_LINK;
1334
1469
  exports.MKVPlayer = MKVPlayer;
1335
1470
  exports.ProgressEstimator = ProgressEstimator;
1336
1471
  exports.SimplePlayer = SimplePlayer;
1337
1472
  exports.SubtitleConverter = SubtitleConverter;
1338
1473
  exports.UniversalSubtitleManager = UniversalSubtitleManager;
1474
+ exports.VIDEO_EXTENSIONS = VIDEO_EXTENSIONS;
1475
+ exports.acceptDisclaimer = acceptDisclaimer;
1339
1476
  exports.applyOffsetToVtt = applyOffsetToVtt;
1340
1477
  exports.captureVideoThumbnail = captureVideoThumbnail;
1341
1478
  exports.configureLightBird = configureLightBird;
1342
1479
  exports.createOffsetVttUrl = createOffsetVttUrl;
1343
1480
  exports.createVideoPlayer = createVideoPlayer;
1481
+ exports.destroyWebTorrentClient = destroyWebTorrentClient;
1344
1482
  exports.exportPlaylist = exportPlaylist;
1345
1483
  exports.extractNativeMetadata = extractNativeMetadata;
1346
1484
  exports.formatShortcutKey = formatShortcutKey;
1347
1485
  exports.getFFmpeg = getFFmpeg;
1486
+ exports.getVideoFiles = getVideoFiles;
1487
+ exports.getWebTorrentClient = getWebTorrentClient;
1488
+ exports.hasAcceptedDisclaimer = hasAcceptedDisclaimer;
1489
+ exports.initFeatureFlags = initFeatureFlags;
1348
1490
  exports.isInteractiveElement = isInteractiveElement;
1491
+ exports.isMagnetUri = isMagnetUri;
1492
+ exports.isVideoFile = isVideoFile;
1349
1493
  exports.loadShortcuts = loadShortcuts;
1350
1494
  exports.matchesShortcut = matchesShortcut;
1351
1495
  exports.parseChaptersFromFFmpegLog = parseChaptersFromFFmpegLog;
package/dist/index.d.cts CHANGED
@@ -1,3 +1,4 @@
1
+ import WebTorrent from 'webtorrent';
1
2
  import { FFmpeg } from '@ffmpeg/ffmpeg';
2
3
 
3
4
  interface LightBirdConfig {
@@ -16,9 +17,20 @@ interface PlaylistItem {
16
17
  name: string;
17
18
  url: string;
18
19
  type: 'video' | 'stream';
20
+ /** Set only on items added via magnet link. */
21
+ source?: 'torrent';
19
22
  file?: File;
20
23
  duration?: number;
21
24
  }
25
+ interface TorrentStatus {
26
+ status: 'idle' | 'loading-metadata' | 'ready' | 'error';
27
+ torrentName: string;
28
+ numPeers: number;
29
+ downloadSpeed: number;
30
+ uploadSpeed: number;
31
+ progress: number;
32
+ error: string | null;
33
+ }
22
34
  interface SubtitleCue {
23
35
  startTime: number;
24
36
  endTime: number;
@@ -252,6 +264,65 @@ declare function exportPlaylist(items: PlaylistItem[]): void;
252
264
  /** Parse an M3U / M3U8 text file into playlist item descriptors (without id). */
253
265
  declare function parseM3U8(text: string): Omit<PlaylistItem, "id">[];
254
266
 
267
+ declare const VIDEO_EXTENSIONS: string[];
268
+ declare const DEFAULT_TRACKERS: string[];
269
+ declare const DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
270
+ /**
271
+ * Returns true if the given string is a valid magnet URI.
272
+ */
273
+ declare function isMagnetUri(str: unknown): boolean;
274
+ /**
275
+ * Returns true if the filename has a recognised video extension.
276
+ */
277
+ declare function isVideoFile(name: unknown): boolean;
278
+ /**
279
+ * Filters and returns all video files from a torrent, sorted by path.
280
+ */
281
+ declare function getVideoFiles(torrent: {
282
+ files: Array<{
283
+ name: string;
284
+ path: string;
285
+ length: number;
286
+ }>;
287
+ }): Array<{
288
+ name: string;
289
+ path: string;
290
+ length: number;
291
+ }>;
292
+ /**
293
+ * Returns true if the user has already accepted the magnet disclaimer.
294
+ */
295
+ declare function hasAcceptedDisclaimer(): boolean;
296
+ /**
297
+ * Persists the user's acceptance of the magnet disclaimer.
298
+ */
299
+ declare function acceptDisclaimer(): void;
300
+ /**
301
+ * Returns the lazy singleton WebTorrent client, creating it if necessary.
302
+ * Sets up the local streaming server backed by the service worker.
303
+ */
304
+ declare function getWebTorrentClient(): Promise<InstanceType<typeof WebTorrent>>;
305
+ /**
306
+ * Destroys the WebTorrent client singleton (call on component unmount).
307
+ */
308
+ declare function destroyWebTorrentClient(): void;
309
+
310
+ /**
311
+ * Feature flag keys — use these constants everywhere instead of raw strings.
312
+ */
313
+ declare const FLAG_MAGNET_LINK = "magnet-link-enabled";
314
+ /**
315
+ * Initialise the OpenFeature SDK with the Unleash Frontend API provider.
316
+ * Returns a promise that resolves once Unleash has fetched the initial flag
317
+ * state. Each `useBooleanFlagValue` call supplies its own default, used while
318
+ * the provider is loading or when no provider is configured.
319
+ *
320
+ * Requires the following environment variables (set in .env.local or Vercel):
321
+ * NEXT_PUBLIC_UNLEASH_URL — Frontend API URL
322
+ * NEXT_PUBLIC_UNLEASH_CLIENT_KEY — Frontend API token
323
+ */
324
+ declare function initFeatureFlags(): Promise<void>;
325
+
255
326
  type MediaErrorType = 'aborted' | 'network' | 'decode' | 'unsupported' | 'unknown';
256
327
  interface ParsedMediaError {
257
328
  type: MediaErrorType;
@@ -311,7 +382,15 @@ declare class ProgressEstimator {
311
382
  };
312
383
  }
313
384
 
385
+ /**
386
+ * Returns a lazily-initialised FFmpeg.wasm instance.
387
+ *
388
+ * `@ffmpeg/ffmpeg` and `@ffmpeg/util` are pulled in via dynamic `import()` so
389
+ * the multi-MB FFmpeg code path is never part of the base `@lightbird/core`
390
+ * entry chunk. Consumers that only play HTML5-native formats (MP4/WebM) and
391
+ * never call `getFFmpeg()` download zero FFmpeg code. See issue #54.
392
+ */
314
393
  declare function getFFmpeg(): Promise<FFmpeg>;
315
394
  declare function resetFFmpeg(): void;
316
395
 
317
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, UniversalSubtitleManager, type VideoFilters, type VideoMetadata, type VideoPlayer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, isInteractiveElement, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
396
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import WebTorrent from 'webtorrent';
1
2
  import { FFmpeg } from '@ffmpeg/ffmpeg';
2
3
 
3
4
  interface LightBirdConfig {
@@ -16,9 +17,20 @@ interface PlaylistItem {
16
17
  name: string;
17
18
  url: string;
18
19
  type: 'video' | 'stream';
20
+ /** Set only on items added via magnet link. */
21
+ source?: 'torrent';
19
22
  file?: File;
20
23
  duration?: number;
21
24
  }
25
+ interface TorrentStatus {
26
+ status: 'idle' | 'loading-metadata' | 'ready' | 'error';
27
+ torrentName: string;
28
+ numPeers: number;
29
+ downloadSpeed: number;
30
+ uploadSpeed: number;
31
+ progress: number;
32
+ error: string | null;
33
+ }
22
34
  interface SubtitleCue {
23
35
  startTime: number;
24
36
  endTime: number;
@@ -252,6 +264,65 @@ declare function exportPlaylist(items: PlaylistItem[]): void;
252
264
  /** Parse an M3U / M3U8 text file into playlist item descriptors (without id). */
253
265
  declare function parseM3U8(text: string): Omit<PlaylistItem, "id">[];
254
266
 
267
+ declare const VIDEO_EXTENSIONS: string[];
268
+ declare const DEFAULT_TRACKERS: string[];
269
+ declare const DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
270
+ /**
271
+ * Returns true if the given string is a valid magnet URI.
272
+ */
273
+ declare function isMagnetUri(str: unknown): boolean;
274
+ /**
275
+ * Returns true if the filename has a recognised video extension.
276
+ */
277
+ declare function isVideoFile(name: unknown): boolean;
278
+ /**
279
+ * Filters and returns all video files from a torrent, sorted by path.
280
+ */
281
+ declare function getVideoFiles(torrent: {
282
+ files: Array<{
283
+ name: string;
284
+ path: string;
285
+ length: number;
286
+ }>;
287
+ }): Array<{
288
+ name: string;
289
+ path: string;
290
+ length: number;
291
+ }>;
292
+ /**
293
+ * Returns true if the user has already accepted the magnet disclaimer.
294
+ */
295
+ declare function hasAcceptedDisclaimer(): boolean;
296
+ /**
297
+ * Persists the user's acceptance of the magnet disclaimer.
298
+ */
299
+ declare function acceptDisclaimer(): void;
300
+ /**
301
+ * Returns the lazy singleton WebTorrent client, creating it if necessary.
302
+ * Sets up the local streaming server backed by the service worker.
303
+ */
304
+ declare function getWebTorrentClient(): Promise<InstanceType<typeof WebTorrent>>;
305
+ /**
306
+ * Destroys the WebTorrent client singleton (call on component unmount).
307
+ */
308
+ declare function destroyWebTorrentClient(): void;
309
+
310
+ /**
311
+ * Feature flag keys — use these constants everywhere instead of raw strings.
312
+ */
313
+ declare const FLAG_MAGNET_LINK = "magnet-link-enabled";
314
+ /**
315
+ * Initialise the OpenFeature SDK with the Unleash Frontend API provider.
316
+ * Returns a promise that resolves once Unleash has fetched the initial flag
317
+ * state. Each `useBooleanFlagValue` call supplies its own default, used while
318
+ * the provider is loading or when no provider is configured.
319
+ *
320
+ * Requires the following environment variables (set in .env.local or Vercel):
321
+ * NEXT_PUBLIC_UNLEASH_URL — Frontend API URL
322
+ * NEXT_PUBLIC_UNLEASH_CLIENT_KEY — Frontend API token
323
+ */
324
+ declare function initFeatureFlags(): Promise<void>;
325
+
255
326
  type MediaErrorType = 'aborted' | 'network' | 'decode' | 'unsupported' | 'unknown';
256
327
  interface ParsedMediaError {
257
328
  type: MediaErrorType;
@@ -311,7 +382,15 @@ declare class ProgressEstimator {
311
382
  };
312
383
  }
313
384
 
385
+ /**
386
+ * Returns a lazily-initialised FFmpeg.wasm instance.
387
+ *
388
+ * `@ffmpeg/ffmpeg` and `@ffmpeg/util` are pulled in via dynamic `import()` so
389
+ * the multi-MB FFmpeg code path is never part of the base `@lightbird/core`
390
+ * entry chunk. Consumers that only play HTML5-native formats (MP4/WebM) and
391
+ * never call `getFFmpeg()` download zero FFmpeg code. See issue #54.
392
+ */
314
393
  declare function getFFmpeg(): Promise<FFmpeg>;
315
394
  declare function resetFFmpeg(): void;
316
395
 
317
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, UniversalSubtitleManager, type VideoFilters, type VideoMetadata, type VideoPlayer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, isInteractiveElement, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
396
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { compile } from 'ass-compiler';
2
- import { FFmpeg } from '@ffmpeg/ffmpeg';
3
- import { toBlobURL } from '@ffmpeg/util';
2
+ import { OpenFeature } from '@openfeature/web-sdk';
3
+ import { UnleashWebProvider } from '@openfeature/unleash-web-provider';
4
4
 
5
5
  // src/config.ts
6
6
  var config = {};
@@ -1101,6 +1101,132 @@ function parseM3U8(text) {
1101
1101
  return items;
1102
1102
  }
1103
1103
 
1104
+ // src/magnet-player.ts
1105
+ var VIDEO_EXTENSIONS = [
1106
+ "mp4",
1107
+ "mkv",
1108
+ "avi",
1109
+ "mov",
1110
+ "wmv",
1111
+ "flv",
1112
+ "webm",
1113
+ "m4v",
1114
+ "ts",
1115
+ "m2ts",
1116
+ "ogv",
1117
+ "ogg",
1118
+ "divx",
1119
+ "xvid",
1120
+ "rmvb",
1121
+ "rm"
1122
+ ];
1123
+ var DEFAULT_TRACKERS = [
1124
+ "wss://tracker.openwebtorrent.com",
1125
+ "wss://tracker.btorrent.xyz",
1126
+ "wss://tracker.fastcast.nz"
1127
+ ];
1128
+ var DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
1129
+ function isMagnetUri(str) {
1130
+ if (typeof str !== "string" || !str.trim()) return false;
1131
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
1132
+ }
1133
+ function isVideoFile(name) {
1134
+ if (typeof name !== "string" || !name) return false;
1135
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
1136
+ return VIDEO_EXTENSIONS.includes(ext);
1137
+ }
1138
+ function getVideoFiles(torrent) {
1139
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
1140
+ }
1141
+ function hasAcceptedDisclaimer() {
1142
+ try {
1143
+ return localStorage.getItem(DISCLAIMER_KEY) === "true";
1144
+ } catch {
1145
+ return false;
1146
+ }
1147
+ }
1148
+ function acceptDisclaimer() {
1149
+ try {
1150
+ localStorage.setItem(DISCLAIMER_KEY, "true");
1151
+ } catch {
1152
+ }
1153
+ }
1154
+ var wtClient = null;
1155
+ var wtClientPromise = null;
1156
+ var swPromise = null;
1157
+ async function ensureServiceWorker() {
1158
+ if (swPromise) return swPromise;
1159
+ swPromise = (async () => {
1160
+ if (!("serviceWorker" in navigator)) {
1161
+ throw new Error("Service workers are not supported in this browser");
1162
+ }
1163
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
1164
+ if (existing?.active) return existing;
1165
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
1166
+ scope: "/"
1167
+ });
1168
+ if (reg.active) return reg;
1169
+ return new Promise((resolve, reject) => {
1170
+ const sw = reg.installing ?? reg.waiting;
1171
+ if (!sw) {
1172
+ if (reg.active) return resolve(reg);
1173
+ return reject(new Error("No service worker found after registration"));
1174
+ }
1175
+ const onStateChange = () => {
1176
+ if (sw.state === "activated") {
1177
+ sw.removeEventListener("statechange", onStateChange);
1178
+ resolve(reg);
1179
+ } else if (sw.state === "redundant") {
1180
+ sw.removeEventListener("statechange", onStateChange);
1181
+ reject(new Error("Service worker became redundant during activation"));
1182
+ }
1183
+ };
1184
+ sw.addEventListener("statechange", onStateChange);
1185
+ });
1186
+ })();
1187
+ return swPromise;
1188
+ }
1189
+ async function getWebTorrentClient() {
1190
+ if (wtClient && !wtClient.destroyed) return wtClient;
1191
+ if (wtClientPromise) return wtClientPromise;
1192
+ wtClientPromise = (async () => {
1193
+ const { default: WebTorrentClass } = await import('webtorrent');
1194
+ const client = new WebTorrentClass();
1195
+ wtClient = client;
1196
+ try {
1197
+ const registration = await ensureServiceWorker();
1198
+ if (!client._server) {
1199
+ client.createServer({ controller: registration });
1200
+ }
1201
+ } catch (err) {
1202
+ console.warn("[magnet-player] Service worker setup failed:", err);
1203
+ }
1204
+ return client;
1205
+ })();
1206
+ return wtClientPromise;
1207
+ }
1208
+ function destroyWebTorrentClient() {
1209
+ if (wtClient && !wtClient.destroyed) {
1210
+ wtClient.destroy();
1211
+ }
1212
+ wtClient = null;
1213
+ wtClientPromise = null;
1214
+ }
1215
+ var FLAG_MAGNET_LINK = "magnet-link-enabled";
1216
+ function initFeatureFlags() {
1217
+ const url = process.env.NEXT_PUBLIC_UNLEASH_URL ?? "";
1218
+ const clientKey = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY ?? "";
1219
+ if (!url || !clientKey) {
1220
+ console.warn(
1221
+ "[feature-flags] NEXT_PUBLIC_UNLEASH_URL and/or NEXT_PUBLIC_UNLEASH_CLIENT_KEY are not set \u2014 feature flags will use their default values (magnet link enabled by default)."
1222
+ );
1223
+ return Promise.resolve();
1224
+ }
1225
+ return OpenFeature.setProviderAndWait(
1226
+ new UnleashWebProvider({ url, clientKey, appName: "lightbird" })
1227
+ );
1228
+ }
1229
+
1104
1230
  // src/utils/media-error.ts
1105
1231
  var MEDIA_ERR_ABORTED = 1;
1106
1232
  var MEDIA_ERR_NETWORK = 2;
@@ -1302,6 +1428,8 @@ var ProgressEstimator = class {
1302
1428
  }
1303
1429
  // No reset() method — create a new instance per file load instead.
1304
1430
  };
1431
+
1432
+ // src/utils/ffmpeg-singleton.ts
1305
1433
  var instance = null;
1306
1434
  var loading = null;
1307
1435
  var defaultCDN = "https://unpkg.com/@ffmpeg/core@0.12.10/dist/umd";
@@ -1309,6 +1437,10 @@ async function getFFmpeg() {
1309
1437
  if (instance) return instance;
1310
1438
  if (loading) return loading;
1311
1439
  loading = (async () => {
1440
+ const [{ FFmpeg }, { toBlobURL }] = await Promise.all([
1441
+ import('@ffmpeg/ffmpeg'),
1442
+ import('@ffmpeg/util')
1443
+ ]);
1312
1444
  const ffmpeg = new FFmpeg();
1313
1445
  const baseURL = getConfig().ffmpegCDN || defaultCDN;
1314
1446
  await ffmpeg.load({
@@ -1325,4 +1457,4 @@ function resetFFmpeg() {
1325
1457
  loading = null;
1326
1458
  }
1327
1459
 
1328
- export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, isInteractiveElement, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
1460
+ export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, VIDEO_EXTENSIONS, acceptDisclaimer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
@@ -902,9 +902,251 @@ function useChapters(videoRef, playerRef) {
902
902
  return { chapters, currentChapter, goToChapter };
903
903
  }
904
904
 
905
+ // src/magnet-player.ts
906
+ var VIDEO_EXTENSIONS2 = [
907
+ "mp4",
908
+ "mkv",
909
+ "avi",
910
+ "mov",
911
+ "wmv",
912
+ "flv",
913
+ "webm",
914
+ "m4v",
915
+ "ts",
916
+ "m2ts",
917
+ "ogv",
918
+ "ogg",
919
+ "divx",
920
+ "xvid",
921
+ "rmvb",
922
+ "rm"
923
+ ];
924
+ var DEFAULT_TRACKERS = [
925
+ "wss://tracker.openwebtorrent.com",
926
+ "wss://tracker.btorrent.xyz",
927
+ "wss://tracker.fastcast.nz"
928
+ ];
929
+ function isMagnetUri(str) {
930
+ if (typeof str !== "string" || !str.trim()) return false;
931
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
932
+ }
933
+ function isVideoFile(name) {
934
+ if (typeof name !== "string" || !name) return false;
935
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
936
+ return VIDEO_EXTENSIONS2.includes(ext);
937
+ }
938
+ function getVideoFiles(torrent) {
939
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
940
+ }
941
+ var wtClient = null;
942
+ var wtClientPromise = null;
943
+ var swPromise = null;
944
+ async function ensureServiceWorker() {
945
+ if (swPromise) return swPromise;
946
+ swPromise = (async () => {
947
+ if (!("serviceWorker" in navigator)) {
948
+ throw new Error("Service workers are not supported in this browser");
949
+ }
950
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
951
+ if (existing?.active) return existing;
952
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
953
+ scope: "/"
954
+ });
955
+ if (reg.active) return reg;
956
+ return new Promise((resolve, reject) => {
957
+ const sw = reg.installing ?? reg.waiting;
958
+ if (!sw) {
959
+ if (reg.active) return resolve(reg);
960
+ return reject(new Error("No service worker found after registration"));
961
+ }
962
+ const onStateChange = () => {
963
+ if (sw.state === "activated") {
964
+ sw.removeEventListener("statechange", onStateChange);
965
+ resolve(reg);
966
+ } else if (sw.state === "redundant") {
967
+ sw.removeEventListener("statechange", onStateChange);
968
+ reject(new Error("Service worker became redundant during activation"));
969
+ }
970
+ };
971
+ sw.addEventListener("statechange", onStateChange);
972
+ });
973
+ })();
974
+ return swPromise;
975
+ }
976
+ async function getWebTorrentClient() {
977
+ if (wtClient && !wtClient.destroyed) return wtClient;
978
+ if (wtClientPromise) return wtClientPromise;
979
+ wtClientPromise = (async () => {
980
+ const { default: WebTorrentClass } = await import('webtorrent');
981
+ const client = new WebTorrentClass();
982
+ wtClient = client;
983
+ try {
984
+ const registration = await ensureServiceWorker();
985
+ if (!client._server) {
986
+ client.createServer({ controller: registration });
987
+ }
988
+ } catch (err) {
989
+ console.warn("[magnet-player] Service worker setup failed:", err);
990
+ }
991
+ return client;
992
+ })();
993
+ return wtClientPromise;
994
+ }
995
+ function destroyWebTorrentClient() {
996
+ if (wtClient && !wtClient.destroyed) {
997
+ wtClient.destroy();
998
+ }
999
+ wtClient = null;
1000
+ wtClientPromise = null;
1001
+ }
1002
+
1003
+ // src/react/use-magnet.ts
1004
+ var METADATA_TIMEOUT_MS = 3e4;
1005
+ var INITIAL_STATUS = {
1006
+ status: "idle",
1007
+ torrentName: "",
1008
+ numPeers: 0,
1009
+ downloadSpeed: 0,
1010
+ uploadSpeed: 0,
1011
+ progress: 0,
1012
+ error: null
1013
+ };
1014
+ function useMagnet() {
1015
+ const [torrentStatus, setTorrentStatus] = react.useState(INITIAL_STATUS);
1016
+ const activeTorrentRef = react.useRef(null);
1017
+ react.useEffect(() => {
1018
+ return () => {
1019
+ destroyWebTorrentClient();
1020
+ };
1021
+ }, []);
1022
+ const destroyMagnet = react.useCallback(() => {
1023
+ if (activeTorrentRef.current && !activeTorrentRef.current.destroyed) {
1024
+ activeTorrentRef.current.destroy();
1025
+ }
1026
+ activeTorrentRef.current = null;
1027
+ setTorrentStatus(INITIAL_STATUS);
1028
+ }, []);
1029
+ const addMagnet = react.useCallback(async (uri) => {
1030
+ if (!isMagnetUri(uri)) {
1031
+ throw new Error("Not a valid magnet link");
1032
+ }
1033
+ if (activeTorrentRef.current && !activeTorrentRef.current.destroyed) {
1034
+ activeTorrentRef.current.destroy();
1035
+ }
1036
+ activeTorrentRef.current = null;
1037
+ setTorrentStatus({
1038
+ ...INITIAL_STATUS,
1039
+ status: "loading-metadata",
1040
+ error: null
1041
+ });
1042
+ let client;
1043
+ try {
1044
+ client = await getWebTorrentClient();
1045
+ } catch {
1046
+ const msg = "Could not initialise torrent client";
1047
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1048
+ throw new Error(msg);
1049
+ }
1050
+ return new Promise((resolve, reject) => {
1051
+ let settled = false;
1052
+ let torrent;
1053
+ const settle = (fn) => {
1054
+ if (settled) return;
1055
+ settled = true;
1056
+ fn();
1057
+ };
1058
+ const timeout = setTimeout(() => {
1059
+ settle(() => {
1060
+ torrent?.destroy?.();
1061
+ activeTorrentRef.current = null;
1062
+ const msg = "Could not connect to peers. Check the link and try again.";
1063
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1064
+ reject(new Error(msg));
1065
+ });
1066
+ }, METADATA_TIMEOUT_MS);
1067
+ try {
1068
+ torrent = client.add(uri.trim(), { announce: DEFAULT_TRACKERS });
1069
+ activeTorrentRef.current = torrent;
1070
+ } catch (err) {
1071
+ settle(() => {
1072
+ clearTimeout(timeout);
1073
+ const msg = "Failed to add torrent";
1074
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1075
+ reject(new Error(msg));
1076
+ });
1077
+ return;
1078
+ }
1079
+ torrent.on("error", (err) => {
1080
+ settle(() => {
1081
+ clearTimeout(timeout);
1082
+ torrent?.destroy?.();
1083
+ activeTorrentRef.current = null;
1084
+ const msg = err?.message ?? "Torrent error";
1085
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1086
+ reject(new Error(msg));
1087
+ });
1088
+ });
1089
+ torrent.on("ready", () => {
1090
+ clearTimeout(timeout);
1091
+ const videoFiles = getVideoFiles(torrent);
1092
+ const streamable = videoFiles.filter(
1093
+ (file) => Boolean(file.streamURL)
1094
+ );
1095
+ if (streamable.length === 0) {
1096
+ settle(() => {
1097
+ torrent.destroy();
1098
+ activeTorrentRef.current = null;
1099
+ const msg = videoFiles.length === 0 ? "No video files found in this torrent" : "Video files in this torrent could not be made streamable";
1100
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1101
+ reject(new Error(msg));
1102
+ });
1103
+ return;
1104
+ }
1105
+ settle(() => {
1106
+ setTorrentStatus((s) => ({
1107
+ ...s,
1108
+ status: "ready",
1109
+ torrentName: torrent.name ?? "",
1110
+ progress: torrent.progress ?? 0
1111
+ }));
1112
+ const items = streamable.map((file) => ({
1113
+ id: crypto.randomUUID(),
1114
+ name: file.name,
1115
+ url: file.streamURL,
1116
+ type: "stream",
1117
+ source: "torrent",
1118
+ duration: void 0
1119
+ }));
1120
+ resolve(items);
1121
+ });
1122
+ torrent.on("download", () => {
1123
+ setTorrentStatus((s) => ({
1124
+ ...s,
1125
+ numPeers: torrent.numPeers ?? 0,
1126
+ downloadSpeed: torrent.downloadSpeed ?? 0,
1127
+ uploadSpeed: torrent.uploadSpeed ?? 0,
1128
+ progress: torrent.progress ?? 0
1129
+ }));
1130
+ });
1131
+ torrent.on("done", () => {
1132
+ setTorrentStatus((s) => ({ ...s, progress: 1 }));
1133
+ });
1134
+ torrent.on("wire", () => {
1135
+ setTorrentStatus((s) => ({
1136
+ ...s,
1137
+ numPeers: torrent.numPeers ?? 0
1138
+ }));
1139
+ });
1140
+ });
1141
+ });
1142
+ }, []);
1143
+ return { torrentStatus, addMagnet, destroyMagnet };
1144
+ }
1145
+
905
1146
  exports.useChapters = useChapters;
906
1147
  exports.useFullscreen = useFullscreen;
907
1148
  exports.useKeyboardShortcuts = useKeyboardShortcuts;
1149
+ exports.useMagnet = useMagnet;
908
1150
  exports.useMediaSession = useMediaSession;
909
1151
  exports.usePictureInPicture = usePictureInPicture;
910
1152
  exports.usePlaylist = usePlaylist;
@@ -29,9 +29,20 @@ interface PlaylistItem {
29
29
  name: string;
30
30
  url: string;
31
31
  type: 'video' | 'stream';
32
+ /** Set only on items added via magnet link. */
33
+ source?: 'torrent';
32
34
  file?: File;
33
35
  duration?: number;
34
36
  }
37
+ interface TorrentStatus {
38
+ status: 'idle' | 'loading-metadata' | 'ready' | 'error';
39
+ torrentName: string;
40
+ numPeers: number;
41
+ downloadSpeed: number;
42
+ uploadSpeed: number;
43
+ progress: number;
44
+ error: string | null;
45
+ }
35
46
  interface SubtitleCue {
36
47
  startTime: number;
37
48
  endTime: number;
@@ -249,4 +260,16 @@ declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: R
249
260
  goToChapter: (index: number) => void;
250
261
  };
251
262
 
252
- export { type ShortcutHandlers, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
263
+ interface UseMagnetReturn {
264
+ torrentStatus: TorrentStatus;
265
+ /**
266
+ * Resolves with the playlist items (one per video file in the torrent).
267
+ * Throws an Error with a user-facing message on failure.
268
+ */
269
+ addMagnet: (uri: string) => Promise<PlaylistItem[]>;
270
+ /** Destroys the active torrent. */
271
+ destroyMagnet: () => void;
272
+ }
273
+ declare function useMagnet(): UseMagnetReturn;
274
+
275
+ export { type ShortcutHandlers, type UseMagnetReturn, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -29,9 +29,20 @@ interface PlaylistItem {
29
29
  name: string;
30
30
  url: string;
31
31
  type: 'video' | 'stream';
32
+ /** Set only on items added via magnet link. */
33
+ source?: 'torrent';
32
34
  file?: File;
33
35
  duration?: number;
34
36
  }
37
+ interface TorrentStatus {
38
+ status: 'idle' | 'loading-metadata' | 'ready' | 'error';
39
+ torrentName: string;
40
+ numPeers: number;
41
+ downloadSpeed: number;
42
+ uploadSpeed: number;
43
+ progress: number;
44
+ error: string | null;
45
+ }
35
46
  interface SubtitleCue {
36
47
  startTime: number;
37
48
  endTime: number;
@@ -249,4 +260,16 @@ declare function useChapters(videoRef: RefObject<HTMLVideoElement>, playerRef: R
249
260
  goToChapter: (index: number) => void;
250
261
  };
251
262
 
252
- export { type ShortcutHandlers, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
263
+ interface UseMagnetReturn {
264
+ torrentStatus: TorrentStatus;
265
+ /**
266
+ * Resolves with the playlist items (one per video file in the torrent).
267
+ * Throws an Error with a user-facing message on failure.
268
+ */
269
+ addMagnet: (uri: string) => Promise<PlaylistItem[]>;
270
+ /** Destroys the active torrent. */
271
+ destroyMagnet: () => void;
272
+ }
273
+ declare function useMagnet(): UseMagnetReturn;
274
+
275
+ export { type ShortcutHandlers, type UseMagnetReturn, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -900,4 +900,245 @@ function useChapters(videoRef, playerRef) {
900
900
  return { chapters, currentChapter, goToChapter };
901
901
  }
902
902
 
903
- export { useChapters, useFullscreen, useKeyboardShortcuts, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
903
+ // src/magnet-player.ts
904
+ var VIDEO_EXTENSIONS2 = [
905
+ "mp4",
906
+ "mkv",
907
+ "avi",
908
+ "mov",
909
+ "wmv",
910
+ "flv",
911
+ "webm",
912
+ "m4v",
913
+ "ts",
914
+ "m2ts",
915
+ "ogv",
916
+ "ogg",
917
+ "divx",
918
+ "xvid",
919
+ "rmvb",
920
+ "rm"
921
+ ];
922
+ var DEFAULT_TRACKERS = [
923
+ "wss://tracker.openwebtorrent.com",
924
+ "wss://tracker.btorrent.xyz",
925
+ "wss://tracker.fastcast.nz"
926
+ ];
927
+ function isMagnetUri(str) {
928
+ if (typeof str !== "string" || !str.trim()) return false;
929
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
930
+ }
931
+ function isVideoFile(name) {
932
+ if (typeof name !== "string" || !name) return false;
933
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
934
+ return VIDEO_EXTENSIONS2.includes(ext);
935
+ }
936
+ function getVideoFiles(torrent) {
937
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
938
+ }
939
+ var wtClient = null;
940
+ var wtClientPromise = null;
941
+ var swPromise = null;
942
+ async function ensureServiceWorker() {
943
+ if (swPromise) return swPromise;
944
+ swPromise = (async () => {
945
+ if (!("serviceWorker" in navigator)) {
946
+ throw new Error("Service workers are not supported in this browser");
947
+ }
948
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
949
+ if (existing?.active) return existing;
950
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
951
+ scope: "/"
952
+ });
953
+ if (reg.active) return reg;
954
+ return new Promise((resolve, reject) => {
955
+ const sw = reg.installing ?? reg.waiting;
956
+ if (!sw) {
957
+ if (reg.active) return resolve(reg);
958
+ return reject(new Error("No service worker found after registration"));
959
+ }
960
+ const onStateChange = () => {
961
+ if (sw.state === "activated") {
962
+ sw.removeEventListener("statechange", onStateChange);
963
+ resolve(reg);
964
+ } else if (sw.state === "redundant") {
965
+ sw.removeEventListener("statechange", onStateChange);
966
+ reject(new Error("Service worker became redundant during activation"));
967
+ }
968
+ };
969
+ sw.addEventListener("statechange", onStateChange);
970
+ });
971
+ })();
972
+ return swPromise;
973
+ }
974
+ async function getWebTorrentClient() {
975
+ if (wtClient && !wtClient.destroyed) return wtClient;
976
+ if (wtClientPromise) return wtClientPromise;
977
+ wtClientPromise = (async () => {
978
+ const { default: WebTorrentClass } = await import('webtorrent');
979
+ const client = new WebTorrentClass();
980
+ wtClient = client;
981
+ try {
982
+ const registration = await ensureServiceWorker();
983
+ if (!client._server) {
984
+ client.createServer({ controller: registration });
985
+ }
986
+ } catch (err) {
987
+ console.warn("[magnet-player] Service worker setup failed:", err);
988
+ }
989
+ return client;
990
+ })();
991
+ return wtClientPromise;
992
+ }
993
+ function destroyWebTorrentClient() {
994
+ if (wtClient && !wtClient.destroyed) {
995
+ wtClient.destroy();
996
+ }
997
+ wtClient = null;
998
+ wtClientPromise = null;
999
+ }
1000
+
1001
+ // src/react/use-magnet.ts
1002
+ var METADATA_TIMEOUT_MS = 3e4;
1003
+ var INITIAL_STATUS = {
1004
+ status: "idle",
1005
+ torrentName: "",
1006
+ numPeers: 0,
1007
+ downloadSpeed: 0,
1008
+ uploadSpeed: 0,
1009
+ progress: 0,
1010
+ error: null
1011
+ };
1012
+ function useMagnet() {
1013
+ const [torrentStatus, setTorrentStatus] = useState(INITIAL_STATUS);
1014
+ const activeTorrentRef = useRef(null);
1015
+ useEffect(() => {
1016
+ return () => {
1017
+ destroyWebTorrentClient();
1018
+ };
1019
+ }, []);
1020
+ const destroyMagnet = useCallback(() => {
1021
+ if (activeTorrentRef.current && !activeTorrentRef.current.destroyed) {
1022
+ activeTorrentRef.current.destroy();
1023
+ }
1024
+ activeTorrentRef.current = null;
1025
+ setTorrentStatus(INITIAL_STATUS);
1026
+ }, []);
1027
+ const addMagnet = useCallback(async (uri) => {
1028
+ if (!isMagnetUri(uri)) {
1029
+ throw new Error("Not a valid magnet link");
1030
+ }
1031
+ if (activeTorrentRef.current && !activeTorrentRef.current.destroyed) {
1032
+ activeTorrentRef.current.destroy();
1033
+ }
1034
+ activeTorrentRef.current = null;
1035
+ setTorrentStatus({
1036
+ ...INITIAL_STATUS,
1037
+ status: "loading-metadata",
1038
+ error: null
1039
+ });
1040
+ let client;
1041
+ try {
1042
+ client = await getWebTorrentClient();
1043
+ } catch {
1044
+ const msg = "Could not initialise torrent client";
1045
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1046
+ throw new Error(msg);
1047
+ }
1048
+ return new Promise((resolve, reject) => {
1049
+ let settled = false;
1050
+ let torrent;
1051
+ const settle = (fn) => {
1052
+ if (settled) return;
1053
+ settled = true;
1054
+ fn();
1055
+ };
1056
+ const timeout = setTimeout(() => {
1057
+ settle(() => {
1058
+ torrent?.destroy?.();
1059
+ activeTorrentRef.current = null;
1060
+ const msg = "Could not connect to peers. Check the link and try again.";
1061
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1062
+ reject(new Error(msg));
1063
+ });
1064
+ }, METADATA_TIMEOUT_MS);
1065
+ try {
1066
+ torrent = client.add(uri.trim(), { announce: DEFAULT_TRACKERS });
1067
+ activeTorrentRef.current = torrent;
1068
+ } catch (err) {
1069
+ settle(() => {
1070
+ clearTimeout(timeout);
1071
+ const msg = "Failed to add torrent";
1072
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1073
+ reject(new Error(msg));
1074
+ });
1075
+ return;
1076
+ }
1077
+ torrent.on("error", (err) => {
1078
+ settle(() => {
1079
+ clearTimeout(timeout);
1080
+ torrent?.destroy?.();
1081
+ activeTorrentRef.current = null;
1082
+ const msg = err?.message ?? "Torrent error";
1083
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1084
+ reject(new Error(msg));
1085
+ });
1086
+ });
1087
+ torrent.on("ready", () => {
1088
+ clearTimeout(timeout);
1089
+ const videoFiles = getVideoFiles(torrent);
1090
+ const streamable = videoFiles.filter(
1091
+ (file) => Boolean(file.streamURL)
1092
+ );
1093
+ if (streamable.length === 0) {
1094
+ settle(() => {
1095
+ torrent.destroy();
1096
+ activeTorrentRef.current = null;
1097
+ const msg = videoFiles.length === 0 ? "No video files found in this torrent" : "Video files in this torrent could not be made streamable";
1098
+ setTorrentStatus((s) => ({ ...s, status: "error", error: msg }));
1099
+ reject(new Error(msg));
1100
+ });
1101
+ return;
1102
+ }
1103
+ settle(() => {
1104
+ setTorrentStatus((s) => ({
1105
+ ...s,
1106
+ status: "ready",
1107
+ torrentName: torrent.name ?? "",
1108
+ progress: torrent.progress ?? 0
1109
+ }));
1110
+ const items = streamable.map((file) => ({
1111
+ id: crypto.randomUUID(),
1112
+ name: file.name,
1113
+ url: file.streamURL,
1114
+ type: "stream",
1115
+ source: "torrent",
1116
+ duration: void 0
1117
+ }));
1118
+ resolve(items);
1119
+ });
1120
+ torrent.on("download", () => {
1121
+ setTorrentStatus((s) => ({
1122
+ ...s,
1123
+ numPeers: torrent.numPeers ?? 0,
1124
+ downloadSpeed: torrent.downloadSpeed ?? 0,
1125
+ uploadSpeed: torrent.uploadSpeed ?? 0,
1126
+ progress: torrent.progress ?? 0
1127
+ }));
1128
+ });
1129
+ torrent.on("done", () => {
1130
+ setTorrentStatus((s) => ({ ...s, progress: 1 }));
1131
+ });
1132
+ torrent.on("wire", () => {
1133
+ setTorrentStatus((s) => ({
1134
+ ...s,
1135
+ numPeers: torrent.numPeers ?? 0
1136
+ }));
1137
+ });
1138
+ });
1139
+ });
1140
+ }, []);
1141
+ return { torrentStatus, addMagnet, destroyMagnet };
1142
+ }
1143
+
1144
+ export { useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightbird/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Client-side video player engine. Plays MKV, MP4, WebM with full subtitle, audio track, and chapter support. No server required.",
5
5
  "license": "MIT",
6
6
  "author": "Punyam Singh",
@@ -54,7 +54,10 @@
54
54
  ],
55
55
  "sideEffects": false,
56
56
  "dependencies": {
57
- "ass-compiler": "^0.1.16"
57
+ "@openfeature/unleash-web-provider": "^0.1.1",
58
+ "@openfeature/web-sdk": "^1.6.0",
59
+ "ass-compiler": "^0.1.16",
60
+ "webtorrent": "^2.8.5"
58
61
  },
59
62
  "optionalDependencies": {
60
63
  "@ffmpeg/core": "^0.12.6",