@lightbird/core 0.1.6 → 0.3.0

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,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var assCompiler = require('ass-compiler');
4
+ var webSdk = require('@openfeature/web-sdk');
5
+ var unleashWebProvider = require('@openfeature/unleash-web-provider');
4
6
  var ffmpeg = require('@ffmpeg/ffmpeg');
5
7
  var util = require('@ffmpeg/util');
6
8
 
@@ -1104,6 +1106,132 @@ function parseM3U8(text) {
1104
1106
  return items;
1105
1107
  }
1106
1108
 
1109
+ // src/magnet-player.ts
1110
+ var VIDEO_EXTENSIONS = [
1111
+ "mp4",
1112
+ "mkv",
1113
+ "avi",
1114
+ "mov",
1115
+ "wmv",
1116
+ "flv",
1117
+ "webm",
1118
+ "m4v",
1119
+ "ts",
1120
+ "m2ts",
1121
+ "ogv",
1122
+ "ogg",
1123
+ "divx",
1124
+ "xvid",
1125
+ "rmvb",
1126
+ "rm"
1127
+ ];
1128
+ var DEFAULT_TRACKERS = [
1129
+ "wss://tracker.openwebtorrent.com",
1130
+ "wss://tracker.btorrent.xyz",
1131
+ "wss://tracker.fastcast.nz"
1132
+ ];
1133
+ var DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
1134
+ function isMagnetUri(str) {
1135
+ if (typeof str !== "string" || !str.trim()) return false;
1136
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
1137
+ }
1138
+ function isVideoFile(name) {
1139
+ if (typeof name !== "string" || !name) return false;
1140
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
1141
+ return VIDEO_EXTENSIONS.includes(ext);
1142
+ }
1143
+ function getVideoFiles(torrent) {
1144
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
1145
+ }
1146
+ function hasAcceptedDisclaimer() {
1147
+ try {
1148
+ return localStorage.getItem(DISCLAIMER_KEY) === "true";
1149
+ } catch {
1150
+ return false;
1151
+ }
1152
+ }
1153
+ function acceptDisclaimer() {
1154
+ try {
1155
+ localStorage.setItem(DISCLAIMER_KEY, "true");
1156
+ } catch {
1157
+ }
1158
+ }
1159
+ var wtClient = null;
1160
+ var wtClientPromise = null;
1161
+ var swPromise = null;
1162
+ async function ensureServiceWorker() {
1163
+ if (swPromise) return swPromise;
1164
+ swPromise = (async () => {
1165
+ if (!("serviceWorker" in navigator)) {
1166
+ throw new Error("Service workers are not supported in this browser");
1167
+ }
1168
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
1169
+ if (existing?.active) return existing;
1170
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
1171
+ scope: "/"
1172
+ });
1173
+ if (reg.active) return reg;
1174
+ return new Promise((resolve, reject) => {
1175
+ const sw = reg.installing ?? reg.waiting;
1176
+ if (!sw) {
1177
+ if (reg.active) return resolve(reg);
1178
+ return reject(new Error("No service worker found after registration"));
1179
+ }
1180
+ const onStateChange = () => {
1181
+ if (sw.state === "activated") {
1182
+ sw.removeEventListener("statechange", onStateChange);
1183
+ resolve(reg);
1184
+ } else if (sw.state === "redundant") {
1185
+ sw.removeEventListener("statechange", onStateChange);
1186
+ reject(new Error("Service worker became redundant during activation"));
1187
+ }
1188
+ };
1189
+ sw.addEventListener("statechange", onStateChange);
1190
+ });
1191
+ })();
1192
+ return swPromise;
1193
+ }
1194
+ async function getWebTorrentClient() {
1195
+ if (wtClient && !wtClient.destroyed) return wtClient;
1196
+ if (wtClientPromise) return wtClientPromise;
1197
+ wtClientPromise = (async () => {
1198
+ const { default: WebTorrentClass } = await import('webtorrent');
1199
+ const client = new WebTorrentClass();
1200
+ wtClient = client;
1201
+ try {
1202
+ const registration = await ensureServiceWorker();
1203
+ if (!client._server) {
1204
+ client.createServer({ controller: registration });
1205
+ }
1206
+ } catch (err) {
1207
+ console.warn("[magnet-player] Service worker setup failed:", err);
1208
+ }
1209
+ return client;
1210
+ })();
1211
+ return wtClientPromise;
1212
+ }
1213
+ function destroyWebTorrentClient() {
1214
+ if (wtClient && !wtClient.destroyed) {
1215
+ wtClient.destroy();
1216
+ }
1217
+ wtClient = null;
1218
+ wtClientPromise = null;
1219
+ }
1220
+ var FLAG_MAGNET_LINK = "magnet-link-enabled";
1221
+ function initFeatureFlags() {
1222
+ const url = process.env.NEXT_PUBLIC_UNLEASH_URL ?? "";
1223
+ const clientKey = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY ?? "";
1224
+ if (!url || !clientKey) {
1225
+ console.warn(
1226
+ "[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)."
1227
+ );
1228
+ return Promise.resolve();
1229
+ }
1230
+ return webSdk.OpenFeature.setProviderAndWait(
1231
+ new unleashWebProvider.UnleashWebProvider({ url, clientKey, appName: "lightbird" })
1232
+ );
1233
+ }
1234
+
1107
1235
  // src/utils/media-error.ts
1108
1236
  var MEDIA_ERR_ABORTED = 1;
1109
1237
  var MEDIA_ERR_NETWORK = 2;
@@ -1331,21 +1459,33 @@ function resetFFmpeg() {
1331
1459
  exports.ASSRenderer = ASSRenderer;
1332
1460
  exports.CancellationError = CancellationError;
1333
1461
  exports.DEFAULT_SHORTCUTS = DEFAULT_SHORTCUTS;
1462
+ exports.DEFAULT_TRACKERS = DEFAULT_TRACKERS;
1463
+ exports.DISCLAIMER_KEY = DISCLAIMER_KEY;
1464
+ exports.FLAG_MAGNET_LINK = FLAG_MAGNET_LINK;
1334
1465
  exports.MKVPlayer = MKVPlayer;
1335
1466
  exports.ProgressEstimator = ProgressEstimator;
1336
1467
  exports.SimplePlayer = SimplePlayer;
1337
1468
  exports.SubtitleConverter = SubtitleConverter;
1338
1469
  exports.UniversalSubtitleManager = UniversalSubtitleManager;
1470
+ exports.VIDEO_EXTENSIONS = VIDEO_EXTENSIONS;
1471
+ exports.acceptDisclaimer = acceptDisclaimer;
1339
1472
  exports.applyOffsetToVtt = applyOffsetToVtt;
1340
1473
  exports.captureVideoThumbnail = captureVideoThumbnail;
1341
1474
  exports.configureLightBird = configureLightBird;
1342
1475
  exports.createOffsetVttUrl = createOffsetVttUrl;
1343
1476
  exports.createVideoPlayer = createVideoPlayer;
1477
+ exports.destroyWebTorrentClient = destroyWebTorrentClient;
1344
1478
  exports.exportPlaylist = exportPlaylist;
1345
1479
  exports.extractNativeMetadata = extractNativeMetadata;
1346
1480
  exports.formatShortcutKey = formatShortcutKey;
1347
1481
  exports.getFFmpeg = getFFmpeg;
1482
+ exports.getVideoFiles = getVideoFiles;
1483
+ exports.getWebTorrentClient = getWebTorrentClient;
1484
+ exports.hasAcceptedDisclaimer = hasAcceptedDisclaimer;
1485
+ exports.initFeatureFlags = initFeatureFlags;
1348
1486
  exports.isInteractiveElement = isInteractiveElement;
1487
+ exports.isMagnetUri = isMagnetUri;
1488
+ exports.isVideoFile = isVideoFile;
1349
1489
  exports.loadShortcuts = loadShortcuts;
1350
1490
  exports.matchesShortcut = matchesShortcut;
1351
1491
  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;
@@ -314,4 +385,4 @@ declare class ProgressEstimator {
314
385
  declare function getFFmpeg(): Promise<FFmpeg>;
315
386
  declare function resetFFmpeg(): void;
316
387
 
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 };
388
+ 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;
@@ -314,4 +385,4 @@ declare class ProgressEstimator {
314
385
  declare function getFFmpeg(): Promise<FFmpeg>;
315
386
  declare function resetFFmpeg(): void;
316
387
 
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 };
388
+ 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,4 +1,6 @@
1
1
  import { compile } from 'ass-compiler';
2
+ import { OpenFeature } from '@openfeature/web-sdk';
3
+ import { UnleashWebProvider } from '@openfeature/unleash-web-provider';
2
4
  import { FFmpeg } from '@ffmpeg/ffmpeg';
3
5
  import { toBlobURL } from '@ffmpeg/util';
4
6
 
@@ -1101,6 +1103,132 @@ function parseM3U8(text) {
1101
1103
  return items;
1102
1104
  }
1103
1105
 
1106
+ // src/magnet-player.ts
1107
+ var VIDEO_EXTENSIONS = [
1108
+ "mp4",
1109
+ "mkv",
1110
+ "avi",
1111
+ "mov",
1112
+ "wmv",
1113
+ "flv",
1114
+ "webm",
1115
+ "m4v",
1116
+ "ts",
1117
+ "m2ts",
1118
+ "ogv",
1119
+ "ogg",
1120
+ "divx",
1121
+ "xvid",
1122
+ "rmvb",
1123
+ "rm"
1124
+ ];
1125
+ var DEFAULT_TRACKERS = [
1126
+ "wss://tracker.openwebtorrent.com",
1127
+ "wss://tracker.btorrent.xyz",
1128
+ "wss://tracker.fastcast.nz"
1129
+ ];
1130
+ var DISCLAIMER_KEY = "lightbird_magnet_disclaimer_accepted";
1131
+ function isMagnetUri(str) {
1132
+ if (typeof str !== "string" || !str.trim()) return false;
1133
+ return /^magnet:\?xt=urn:(btih:([a-f0-9]{40}|[a-z2-7]{32})|btmh:[a-z0-9]{20,})/i.test(str.trim());
1134
+ }
1135
+ function isVideoFile(name) {
1136
+ if (typeof name !== "string" || !name) return false;
1137
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
1138
+ return VIDEO_EXTENSIONS.includes(ext);
1139
+ }
1140
+ function getVideoFiles(torrent) {
1141
+ return [...torrent.files].filter((f) => isVideoFile(f.name)).sort((a, b) => a.path.localeCompare(b.path));
1142
+ }
1143
+ function hasAcceptedDisclaimer() {
1144
+ try {
1145
+ return localStorage.getItem(DISCLAIMER_KEY) === "true";
1146
+ } catch {
1147
+ return false;
1148
+ }
1149
+ }
1150
+ function acceptDisclaimer() {
1151
+ try {
1152
+ localStorage.setItem(DISCLAIMER_KEY, "true");
1153
+ } catch {
1154
+ }
1155
+ }
1156
+ var wtClient = null;
1157
+ var wtClientPromise = null;
1158
+ var swPromise = null;
1159
+ async function ensureServiceWorker() {
1160
+ if (swPromise) return swPromise;
1161
+ swPromise = (async () => {
1162
+ if (!("serviceWorker" in navigator)) {
1163
+ throw new Error("Service workers are not supported in this browser");
1164
+ }
1165
+ const existing = await navigator.serviceWorker.getRegistration("/webtorrent-sw.js");
1166
+ if (existing?.active) return existing;
1167
+ const reg = await navigator.serviceWorker.register("/webtorrent-sw.js", {
1168
+ scope: "/"
1169
+ });
1170
+ if (reg.active) return reg;
1171
+ return new Promise((resolve, reject) => {
1172
+ const sw = reg.installing ?? reg.waiting;
1173
+ if (!sw) {
1174
+ if (reg.active) return resolve(reg);
1175
+ return reject(new Error("No service worker found after registration"));
1176
+ }
1177
+ const onStateChange = () => {
1178
+ if (sw.state === "activated") {
1179
+ sw.removeEventListener("statechange", onStateChange);
1180
+ resolve(reg);
1181
+ } else if (sw.state === "redundant") {
1182
+ sw.removeEventListener("statechange", onStateChange);
1183
+ reject(new Error("Service worker became redundant during activation"));
1184
+ }
1185
+ };
1186
+ sw.addEventListener("statechange", onStateChange);
1187
+ });
1188
+ })();
1189
+ return swPromise;
1190
+ }
1191
+ async function getWebTorrentClient() {
1192
+ if (wtClient && !wtClient.destroyed) return wtClient;
1193
+ if (wtClientPromise) return wtClientPromise;
1194
+ wtClientPromise = (async () => {
1195
+ const { default: WebTorrentClass } = await import('webtorrent');
1196
+ const client = new WebTorrentClass();
1197
+ wtClient = client;
1198
+ try {
1199
+ const registration = await ensureServiceWorker();
1200
+ if (!client._server) {
1201
+ client.createServer({ controller: registration });
1202
+ }
1203
+ } catch (err) {
1204
+ console.warn("[magnet-player] Service worker setup failed:", err);
1205
+ }
1206
+ return client;
1207
+ })();
1208
+ return wtClientPromise;
1209
+ }
1210
+ function destroyWebTorrentClient() {
1211
+ if (wtClient && !wtClient.destroyed) {
1212
+ wtClient.destroy();
1213
+ }
1214
+ wtClient = null;
1215
+ wtClientPromise = null;
1216
+ }
1217
+ var FLAG_MAGNET_LINK = "magnet-link-enabled";
1218
+ function initFeatureFlags() {
1219
+ const url = process.env.NEXT_PUBLIC_UNLEASH_URL ?? "";
1220
+ const clientKey = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY ?? "";
1221
+ if (!url || !clientKey) {
1222
+ console.warn(
1223
+ "[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)."
1224
+ );
1225
+ return Promise.resolve();
1226
+ }
1227
+ return OpenFeature.setProviderAndWait(
1228
+ new UnleashWebProvider({ url, clientKey, appName: "lightbird" })
1229
+ );
1230
+ }
1231
+
1104
1232
  // src/utils/media-error.ts
1105
1233
  var MEDIA_ERR_ABORTED = 1;
1106
1234
  var MEDIA_ERR_NETWORK = 2;
@@ -1325,4 +1453,4 @@ function resetFFmpeg() {
1325
1453
  loading = null;
1326
1454
  }
1327
1455
 
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 };
1456
+ 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.1.6",
3
+ "version": "0.3.0",
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",