mega-stream-proxy 0.1.9 → 0.2.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/README.md +14 -49
- package/dist/megaFile.js +22 -9
- package/dist/{Player/playerAndroid/MegaVideoPlayerAndroid.d.ts → player/MegaVideoPlayer.d.ts} +4 -13
- package/dist/player/MegaVideoPlayer.js +307 -0
- package/dist/{Player/playerweb → player}/PlayerControls.d.ts +2 -14
- package/dist/player/PlayerControls.js +289 -0
- package/dist/{Player/playerweb → player}/SubtitleDisplay.d.ts +3 -3
- package/dist/{Player/playerweb → player}/SubtitleDisplay.js +1 -1
- package/dist/player/index.d.ts +3 -0
- package/dist/player/index.js +3 -0
- package/dist/{Player/playerweb/MegaVideoPlayer.d.ts → playerweb/MegaVideoPlayerWeb.d.ts} +6 -18
- package/dist/{Player/playerweb/MegaVideoPlayer.js → playerweb/MegaVideoPlayerWeb.js} +18 -18
- package/dist/{Player/playerAndroid/PlayerControlsAndroid.d.ts → playerweb/PlayerControlsWeb.d.ts} +2 -16
- package/dist/playerweb/PlayerControlsWeb.js +289 -0
- package/dist/playerweb/SubtitleDisplayWeb.d.ts +15 -0
- package/dist/playerweb/SubtitleDisplayWeb.js +32 -0
- package/dist/playerweb/index.d.ts +3 -0
- package/dist/playerweb/index.js +3 -0
- package/dist/proxy/native.js +11 -3
- package/expo-module.config.json +3 -0
- package/package.json +15 -18
- package/src/megaFile.ts +28 -15
- package/src/player/MegaVideoPlayer.tsx +474 -0
- package/src/{Player/playerweb → player}/PlayerControls.tsx +200 -369
- package/src/{Player/playerweb → player}/SubtitleDisplay.tsx +3 -3
- package/src/player/index.ts +7 -0
- package/src/{Player/playerweb/MegaVideoPlayer.tsx → playerweb/MegaVideoPlayerWeb.tsx} +41 -58
- package/src/playerweb/PlayerControlsWeb.tsx +629 -0
- package/src/playerweb/SubtitleDisplayWeb.tsx +48 -0
- package/src/playerweb/index.ts +3 -0
- package/src/proxy/native.ts +10 -3
- package/dist/Player/native.d.ts +0 -3
- package/dist/Player/native.js +0 -3
- package/dist/Player/playerAndroid/MegaVideoPlayerAndroid.js +0 -624
- package/dist/Player/playerAndroid/PlayerControlsAndroid.js +0 -370
- package/dist/Player/playerAndroid/SubtitleDisplayAndroid.d.ts +0 -21
- package/dist/Player/playerAndroid/SubtitleDisplayAndroid.js +0 -55
- package/dist/Player/playerAndroid/index.d.ts +0 -3
- package/dist/Player/playerAndroid/index.js +0 -3
- package/dist/Player/playerweb/PlayerControls.js +0 -346
- package/dist/Player/playerweb/index.d.ts +0 -3
- package/dist/Player/playerweb/index.js +0 -3
- package/dist/Player/web.d.ts +0 -3
- package/dist/Player/web.js +0 -3
- package/src/Player/playerAndroid/MegaVideoPlayerAndroid.tsx +0 -825
- package/src/Player/playerAndroid/PlayerControlsAndroid.tsx +0 -824
- package/src/Player/playerAndroid/SubtitleDisplayAndroid.tsx +0 -80
- package/src/Player/playerAndroid/index.ts +0 -7
- package/src/Player/playerweb/index.ts +0 -3
|
@@ -1,825 +0,0 @@
|
|
|
1
|
-
// The exact MyMovies episode player (components/MegaVideoPlayer.web.tsx),
|
|
2
|
-
// packaged as a cross-platform component (Android / iOS / web). Driven by the
|
|
3
|
-
// react-native-video `<Video>` component, with ordered part URLs played
|
|
4
|
-
// back-to-back and auto-advancing to the next part when the current one ends.
|
|
5
|
-
// Sources come in as proxy URLs — from `mega-stream-proxy/native`
|
|
6
|
-
// (http://127.0.0.1:<port>/…) on Android/iOS or from the Service Worker
|
|
7
|
-
// (`/__mmstream__/…`) on the web.
|
|
8
|
-
|
|
9
|
-
import React, {
|
|
10
|
-
forwardRef,
|
|
11
|
-
useEffect,
|
|
12
|
-
useImperativeHandle,
|
|
13
|
-
useMemo,
|
|
14
|
-
useState,
|
|
15
|
-
} from "react";
|
|
16
|
-
import {
|
|
17
|
-
ActivityIndicator,
|
|
18
|
-
Platform,
|
|
19
|
-
StyleSheet,
|
|
20
|
-
Text,
|
|
21
|
-
View,
|
|
22
|
-
} from "react-native";
|
|
23
|
-
import { Video } from "react-native-video";
|
|
24
|
-
import { Ionicons } from "@expo/vector-icons";
|
|
25
|
-
|
|
26
|
-
import { PlayerControlsAndroid } from "./PlayerControlsAndroid.js";
|
|
27
|
-
import { SubtitleDisplayAndroid } from "./SubtitleDisplayAndroid.js";
|
|
28
|
-
import {
|
|
29
|
-
loadSubtitles,
|
|
30
|
-
type SubCue,
|
|
31
|
-
type SubtitleFormat,
|
|
32
|
-
type SubtitleTrack,
|
|
33
|
-
} from "../../subtitles/index.js";
|
|
34
|
-
|
|
35
|
-
export type MegaVideoPlayerAndroidProps = {
|
|
36
|
-
/** A playable URL — a direct stream or the first part URL. */
|
|
37
|
-
uri?: string | null;
|
|
38
|
-
/** Ordered additional part URLs for multi-part episodes, played in sequence. */
|
|
39
|
-
sources?: string[];
|
|
40
|
-
title?: string;
|
|
41
|
-
/** Pass your subtitle file here — MEGA links, plain http(s) CORS hosts, or a known extension (.srt/.ass/.vtt). */
|
|
42
|
-
subtitleUrl?: string;
|
|
43
|
-
/** optional format hint (defaults to URL extension, then content sniffing) */
|
|
44
|
-
subtitleFormat?: SubtitleFormat;
|
|
45
|
-
/** language name shown in the player controls for the single `subtitleUrl` track
|
|
46
|
-
* (e.g. "English", "French"). Defaults to "SRT". */
|
|
47
|
-
subtitleLang?: string;
|
|
48
|
-
subtitleTracks?: SubtitleTrack[];
|
|
49
|
-
/** preselect a subtitle track by language on load */
|
|
50
|
-
initialSubtitleLang?: string | null;
|
|
51
|
-
/** shift the cue timestamps by an offset in seconds (can be negative) */
|
|
52
|
-
subtitleOffset?: number;
|
|
53
|
-
/** resolution labels in display order — the FIRST is the default source
|
|
54
|
-
* (usually "HD: <url>"), e.g. ["Default", "1080p", "720p"]. */
|
|
55
|
-
resolutions?: string[];
|
|
56
|
-
/** currently active resolution label */
|
|
57
|
-
resolution?: string;
|
|
58
|
-
/** user picked a resolution label — resolve it to a URL and update `uri`. */
|
|
59
|
-
onSelectResolution?: (label: string) => void;
|
|
60
|
-
autoPlay?: boolean;
|
|
61
|
-
onEnd?: () => void;
|
|
62
|
-
onError?: (message: string) => void;
|
|
63
|
-
onProgress?: (info: { currentTime: number; duration: number }) => void;
|
|
64
|
-
onFullscreenChange?: (fullscreen: boolean) => void;
|
|
65
|
-
width?: number;
|
|
66
|
-
height?: number;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export type MegaVideoPlayerAndroidHandle = {
|
|
70
|
-
/** Loads a new playable URL into the player. */
|
|
71
|
-
add: (source: string, opts?: { title?: string }) => Promise<void>;
|
|
72
|
-
/** Stops playback and clears the current source. */
|
|
73
|
-
stop: () => void;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export const MegaVideoPlayerAndroid = forwardRef<
|
|
77
|
-
MegaVideoPlayerAndroidHandle,
|
|
78
|
-
MegaVideoPlayerAndroidProps
|
|
79
|
-
>(function MegaVideoPlayer(
|
|
80
|
-
{
|
|
81
|
-
uri,
|
|
82
|
-
sources,
|
|
83
|
-
title,
|
|
84
|
-
subtitleUrl,
|
|
85
|
-
subtitleFormat,
|
|
86
|
-
subtitleLang,
|
|
87
|
-
subtitleTracks: subtitleTracksProp,
|
|
88
|
-
initialSubtitleLang,
|
|
89
|
-
subtitleOffset,
|
|
90
|
-
resolutions,
|
|
91
|
-
resolution,
|
|
92
|
-
onSelectResolution,
|
|
93
|
-
autoPlay = true,
|
|
94
|
-
onEnd,
|
|
95
|
-
onError,
|
|
96
|
-
onProgress,
|
|
97
|
-
onFullscreenChange,
|
|
98
|
-
width,
|
|
99
|
-
height,
|
|
100
|
-
},
|
|
101
|
-
ref,
|
|
102
|
-
) {
|
|
103
|
-
const [playing, setPlaying] = useState(false);
|
|
104
|
-
const [buffering, setBuffering] = useState(true);
|
|
105
|
-
const [loading, setLoading] = useState(false);
|
|
106
|
-
const [currentTime, setCurrentTime] = useState(0);
|
|
107
|
-
const [duration, setDuration] = useState(0);
|
|
108
|
-
const [muted, setMuted] = useState(false);
|
|
109
|
-
const [volume, setVolume] = useState(1);
|
|
110
|
-
const [visible, setVisible] = useState(true);
|
|
111
|
-
const [subtitlesOn, setSubtitlesOn] = useState(true);
|
|
112
|
-
const [subFontSize, setSubFontSize] = useState(14);
|
|
113
|
-
const [cues, setCues] = useState<SubCue[]>([]);
|
|
114
|
-
const [subtitleError, setSubtitleError] = useState<string | null>(null);
|
|
115
|
-
const [error, setError] = useState<string | null>(null);
|
|
116
|
-
const [fullscreen, setFullscreen] = useState(false);
|
|
117
|
-
const [controlsBump, setControlsBump] = useState(0);
|
|
118
|
-
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
|
119
|
-
const [tracks, setTracks] = useState<SubtitleTrack[]>([]);
|
|
120
|
-
|
|
121
|
-
// Keep the `<Video>` source object identity stable — a fresh `{ uri }` on
|
|
122
|
-
// every render makes the native player re-evaluate (and on some platforms
|
|
123
|
-
// reload) the source, which resets the playback clock.
|
|
124
|
-
const videoSource = useMemo(
|
|
125
|
-
() => (sourceUri ? { uri: sourceUri } : undefined),
|
|
126
|
-
[sourceUri],
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
const videoRef = React.useRef<any>(null);
|
|
130
|
-
const mountedRef = React.useRef(true);
|
|
131
|
-
const autoplayRef = React.useRef(autoPlay !== false);
|
|
132
|
-
const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
133
|
-
const subTrackIdxRef = React.useRef(0);
|
|
134
|
-
const sourceIndexRef = React.useRef(
|
|
135
|
-
Math.max(0, (sources ?? []).indexOf(uri ?? "")),
|
|
136
|
-
);
|
|
137
|
-
/** sources that auto-advance on end. Quality-switch files are full merged
|
|
138
|
-
* sources, so they stop part auto-advance. */
|
|
139
|
-
const activeSourcesRef = React.useRef<string[]>(sources ?? []);
|
|
140
|
-
/** position to resume a quality switch at, applied once playback is moving */
|
|
141
|
-
const pendingResumeRef = React.useRef<number | null>(null);
|
|
142
|
-
const prevResolutionRef = React.useRef<string | undefined>(resolution);
|
|
143
|
-
/** web-only: the UI's intended play state + element volumes, so the DOM
|
|
144
|
-
* bridge can operate the raw <video> element without React re-renders. */
|
|
145
|
-
const playingRef = React.useRef(false);
|
|
146
|
-
const volumeRef = React.useRef(volume);
|
|
147
|
-
const mutedRef = React.useRef(muted);
|
|
148
|
-
const onErrorRef = React.useRef(onError);
|
|
149
|
-
const onProgressRef = React.useRef(onProgress);
|
|
150
|
-
|
|
151
|
-
// ---- source management ----
|
|
152
|
-
|
|
153
|
-
async function add(source: string, opts?: { title?: string }): Promise<void> {
|
|
154
|
-
if (!source) return;
|
|
155
|
-
setError(null);
|
|
156
|
-
setLoading(true);
|
|
157
|
-
setBuffering(true);
|
|
158
|
-
setSourceUri(source);
|
|
159
|
-
setCurrentTime(0);
|
|
160
|
-
setDuration(0);
|
|
161
|
-
|
|
162
|
-
if (!mountedRef.current) return;
|
|
163
|
-
console.log(`[MegaVideoPlayer] add: ${source} (${opts?.title ?? title})`);
|
|
164
|
-
if (autoplayRef.current) {
|
|
165
|
-
setPlaying(true);
|
|
166
|
-
scheduleHide();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function stop(): void {
|
|
171
|
-
setSourceUri(null);
|
|
172
|
-
setPlaying(false);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
useImperativeHandle(ref, () => ({ add, stop }), [add, stop]);
|
|
176
|
-
|
|
177
|
-
// ---- player controllers ----
|
|
178
|
-
|
|
179
|
-
const playPause = (): void => {
|
|
180
|
-
setPlaying((p) => !p);
|
|
181
|
-
setVisible(true);
|
|
182
|
-
if (Platform.OS === "web") {
|
|
183
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
184
|
-
if (el) {
|
|
185
|
-
try {
|
|
186
|
-
if (playing) {
|
|
187
|
-
el.pause();
|
|
188
|
-
} else {
|
|
189
|
-
setLoading(false);
|
|
190
|
-
const p = el.play();
|
|
191
|
-
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
192
|
-
}
|
|
193
|
-
} catch {}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const skip = (delta: number): void => {
|
|
199
|
-
const next = Math.min(Math.max(currentTime + delta, 0), duration);
|
|
200
|
-
if (Platform.OS === "web") {
|
|
201
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
202
|
-
if (el) {
|
|
203
|
-
try {
|
|
204
|
-
el.currentTime = next;
|
|
205
|
-
} catch {}
|
|
206
|
-
}
|
|
207
|
-
} else {
|
|
208
|
-
videoRef.current?.seek(next);
|
|
209
|
-
}
|
|
210
|
-
setCurrentTime(next);
|
|
211
|
-
setVisible(true);
|
|
212
|
-
scheduleHide();
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const seek = (seconds: number): void => {
|
|
216
|
-
const clamped = Math.min(Math.max(seconds, 0), duration || seconds);
|
|
217
|
-
if (Platform.OS === "web") {
|
|
218
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
219
|
-
if (el) {
|
|
220
|
-
try {
|
|
221
|
-
el.currentTime = clamped;
|
|
222
|
-
} catch {}
|
|
223
|
-
}
|
|
224
|
-
} else {
|
|
225
|
-
videoRef.current?.seek(clamped);
|
|
226
|
-
}
|
|
227
|
-
setCurrentTime(clamped);
|
|
228
|
-
setVisible(true);
|
|
229
|
-
scheduleHide();
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const toggleMute = (): void => {
|
|
233
|
-
setMuted((m) => {
|
|
234
|
-
const next = !m;
|
|
235
|
-
mutedRef.current = next;
|
|
236
|
-
if (Platform.OS === "web") {
|
|
237
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
238
|
-
if (el) {
|
|
239
|
-
try {
|
|
240
|
-
el.muted = next;
|
|
241
|
-
} catch {}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return next;
|
|
245
|
-
});
|
|
246
|
-
setVisible(true);
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const changeVolume = (v: number): void => {
|
|
250
|
-
const clamped = Math.min(Math.max(v, 0), 1);
|
|
251
|
-
volumeRef.current = clamped;
|
|
252
|
-
setVolume(clamped);
|
|
253
|
-
if (clamped > 0) setMuted(false);
|
|
254
|
-
if (Platform.OS === "web") {
|
|
255
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
256
|
-
if (el) {
|
|
257
|
-
try {
|
|
258
|
-
el.volume = clamped;
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const toggleSubtitles = (): void => {
|
|
265
|
-
setSubtitlesOn((s) => !s);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const toggleControls = (): void => {
|
|
269
|
-
setVisible((v) => !v);
|
|
270
|
-
if (!visible && playing) scheduleHide();
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
/** any interaction with the control bar — reset the auto-hide timer */
|
|
274
|
-
const onInteract = (): void => {
|
|
275
|
-
setControlsBump((n) => n + 1);
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const enterFullscreen = (next?: boolean | unknown): void => {
|
|
279
|
-
// PlayerControls calls through onPress, which passes the press event as
|
|
280
|
-
// the first argument — ignore it (it is not the desired fullscreen value).
|
|
281
|
-
const target = typeof next === "boolean" ? next : !fullscreen;
|
|
282
|
-
setFullscreen(target);
|
|
283
|
-
setVisible(true);
|
|
284
|
-
onFullscreenChange?.(target);
|
|
285
|
-
if (Platform.OS === "web") {
|
|
286
|
-
const doc = (globalThis as any)?.document ?? null;
|
|
287
|
-
const el = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
288
|
-
try {
|
|
289
|
-
if (target) {
|
|
290
|
-
el?.requestFullscreen?.();
|
|
291
|
-
} else if (doc?.fullscreenElement) {
|
|
292
|
-
doc.exitFullscreen();
|
|
293
|
-
}
|
|
294
|
-
} catch {}
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const changeSubtitleFontSize = (px: number): void => {
|
|
299
|
-
setSubFontSize(px);
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
const selectSubtitleTrack = (lang: string): void => {
|
|
303
|
-
const idx = tracks.findIndex((t) => t.lang === lang);
|
|
304
|
-
if (idx >= 0) {
|
|
305
|
-
subTrackIdxRef.current = idx;
|
|
306
|
-
setSubtitlesOn(true);
|
|
307
|
-
loadCues(tracks);
|
|
308
|
-
}
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
// ---- react-native-video handlers ----
|
|
312
|
-
|
|
313
|
-
const onVideoLoad = (data: { duration?: number }): void => {
|
|
314
|
-
const d = data.duration ?? duration;
|
|
315
|
-
setDuration(d);
|
|
316
|
-
setBuffering(false);
|
|
317
|
-
setLoading(false);
|
|
318
|
-
console.log(`[MegaVideoPlayer] onLoad duration=${d}`);
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
const onVideoProgress = (data: { currentTime?: number }): void => {
|
|
322
|
-
const t = data.currentTime ?? 0;
|
|
323
|
-
// Always move the clock first — never let a pending quality-switch resume
|
|
324
|
-
// swallow progress events.
|
|
325
|
-
setCurrentTime(t);
|
|
326
|
-
onProgress?.({ currentTime: t, duration });
|
|
327
|
-
|
|
328
|
-
const pending = pendingResumeRef.current;
|
|
329
|
-
// quality switch: resume the previous position once the new stream is
|
|
330
|
-
// actually flowing (t > 0) — seeking too early can stall the load.
|
|
331
|
-
if (pending != null && t > 0 && duration > 0) {
|
|
332
|
-
pendingResumeRef.current = null;
|
|
333
|
-
const target =
|
|
334
|
-
duration > pending + 20 ? pending : Math.max(duration - 20, 0);
|
|
335
|
-
videoRef.current?.seek(target);
|
|
336
|
-
setCurrentTime(target);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
const onVideoPlaybackStateChanged = (data: { isPlaying?: boolean }): void => {
|
|
341
|
-
if (!mountedRef.current) return;
|
|
342
|
-
// Web only: browsers may deny autoplay, so mirror the real element state
|
|
343
|
-
// back into the UI. On native, `isPlaying` drops to false while buffering,
|
|
344
|
-
// which must NOT be interpreted as a user pause.
|
|
345
|
-
if (Platform.OS !== "web") return;
|
|
346
|
-
if (typeof data?.isPlaying === "boolean") {
|
|
347
|
-
setPlaying((prev) => (prev === data.isPlaying ? prev : data.isPlaying!));
|
|
348
|
-
if (data.isPlaying) {
|
|
349
|
-
setBuffering(false);
|
|
350
|
-
setLoading(false);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
const onVideoError = (e: any): void => {
|
|
356
|
-
if (!mountedRef.current) return;
|
|
357
|
-
const message =
|
|
358
|
-
e?.error?.message || e?.error?.errorString || String(e) || "Video error";
|
|
359
|
-
console.warn("[MegaVideoPlayer] onError:", message);
|
|
360
|
-
setError(message);
|
|
361
|
-
setBuffering(false);
|
|
362
|
-
setLoading(false);
|
|
363
|
-
onError?.(message);
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const onVideoEnd = (): void => {
|
|
367
|
-
const list = activeSourcesRef.current;
|
|
368
|
-
const next = list[sourceIndexRef.current + 1];
|
|
369
|
-
if (next) {
|
|
370
|
-
sourceIndexRef.current += 1;
|
|
371
|
-
console.log(
|
|
372
|
-
`[MegaVideoPlayer] advancing to part ${sourceIndexRef.current + 1}/${list.length}`,
|
|
373
|
-
);
|
|
374
|
-
void add(next, { title });
|
|
375
|
-
} else {
|
|
376
|
-
setPlaying(false);
|
|
377
|
-
setVisible(true);
|
|
378
|
-
onEnd?.();
|
|
379
|
-
}
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
// ---- helpers ----
|
|
383
|
-
|
|
384
|
-
function scheduleHide() {
|
|
385
|
-
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
386
|
-
hideTimerRef.current = setTimeout(() => {
|
|
387
|
-
setPlaying((p) => {
|
|
388
|
-
if (p) setVisible(false);
|
|
389
|
-
return p;
|
|
390
|
-
});
|
|
391
|
-
}, 3000);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function loadCues(trackList: SubtitleTrack[] = tracks) {
|
|
395
|
-
const track =
|
|
396
|
-
trackList[Math.min(subTrackIdxRef.current, trackList.length - 1)] ?? null;
|
|
397
|
-
setCues([]);
|
|
398
|
-
setSubtitleError(null);
|
|
399
|
-
if (!track) return;
|
|
400
|
-
loadSubtitles(track.url, { format: track.format ?? undefined })
|
|
401
|
-
.then((c) => {
|
|
402
|
-
if (!mountedRef.current) return;
|
|
403
|
-
setCues(c);
|
|
404
|
-
setSubtitleError(
|
|
405
|
-
c.length === 0 ? "No cues parsed from this subtitle file" : null,
|
|
406
|
-
);
|
|
407
|
-
})
|
|
408
|
-
.catch((e: unknown) => {
|
|
409
|
-
console.warn("[subtitles] load failed:", track.url, e);
|
|
410
|
-
if (mountedRef.current) setSubtitleError("Subtitles unavailable");
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function syncSubtitleTracks() {
|
|
415
|
-
const nextTracks = subtitleTracksProp?.length
|
|
416
|
-
? subtitleTracksProp
|
|
417
|
-
: subtitleUrl
|
|
418
|
-
? [
|
|
419
|
-
{
|
|
420
|
-
lang: subtitleLang ?? "SRT",
|
|
421
|
-
url: subtitleUrl,
|
|
422
|
-
format: subtitleFormat,
|
|
423
|
-
},
|
|
424
|
-
]
|
|
425
|
-
: [];
|
|
426
|
-
setTracks(nextTracks);
|
|
427
|
-
// Preselect the requested track (from the watch screen's subtitle picker)
|
|
428
|
-
if (initialSubtitleLang) {
|
|
429
|
-
const idx = nextTracks.findIndex((t) => t.lang === initialSubtitleLang);
|
|
430
|
-
if (idx >= 0) subTrackIdxRef.current = idx;
|
|
431
|
-
}
|
|
432
|
-
loadCues(nextTracks);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// ---- effects ----
|
|
436
|
-
|
|
437
|
-
useEffect(() => {
|
|
438
|
-
mountedRef.current = true;
|
|
439
|
-
autoplayRef.current = autoPlay !== false;
|
|
440
|
-
return () => {
|
|
441
|
-
mountedRef.current = false;
|
|
442
|
-
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
443
|
-
};
|
|
444
|
-
}, [autoPlay]);
|
|
445
|
-
|
|
446
|
-
useEffect(() => {
|
|
447
|
-
if (!uri) return;
|
|
448
|
-
sourceIndexRef.current = Math.max(0, (sources ?? []).indexOf(uri));
|
|
449
|
-
const prevRes = prevResolutionRef.current;
|
|
450
|
-
const qualitySwitch = resolution != null && resolution !== prevRes;
|
|
451
|
-
prevResolutionRef.current = resolution;
|
|
452
|
-
if (qualitySwitch) {
|
|
453
|
-
pendingResumeRef.current = currentTime;
|
|
454
|
-
const isDefaultChain = (sources ?? []).includes(uri ?? "");
|
|
455
|
-
// a resolution file is one merged source — don't auto-advance through it
|
|
456
|
-
activeSourcesRef.current = isDefaultChain ? (sources ?? []) : [];
|
|
457
|
-
} else {
|
|
458
|
-
pendingResumeRef.current = null;
|
|
459
|
-
activeSourcesRef.current = sources ?? [];
|
|
460
|
-
}
|
|
461
|
-
void add(uri, { title });
|
|
462
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
463
|
-
}, [uri]);
|
|
464
|
-
|
|
465
|
-
useEffect(() => {
|
|
466
|
-
activeSourcesRef.current = sources ?? [];
|
|
467
|
-
}, [sources]);
|
|
468
|
-
|
|
469
|
-
useEffect(() => {
|
|
470
|
-
syncSubtitleTracks();
|
|
471
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
472
|
-
}, [subtitleTracksProp, subtitleUrl]);
|
|
473
|
-
|
|
474
|
-
useEffect(() => {
|
|
475
|
-
if (!visible || !playing) return;
|
|
476
|
-
const t = setTimeout(() => setVisible(false), 3000);
|
|
477
|
-
return () => clearTimeout(t);
|
|
478
|
-
}, [visible, playing, controlsBump]);
|
|
479
|
-
|
|
480
|
-
// Safety net for the playback clock: some platforms/builds deliver
|
|
481
|
-
// `onProgress` sparsely. While playing, poll the player position directly so
|
|
482
|
-
// the timer, scrubber and subtitles keep advancing.
|
|
483
|
-
useEffect(() => {
|
|
484
|
-
if (!playing) return;
|
|
485
|
-
let cancelled = false;
|
|
486
|
-
const id = setInterval(async () => {
|
|
487
|
-
try {
|
|
488
|
-
const pos = await videoRef.current?.getCurrentPosition?.();
|
|
489
|
-
if (cancelled || typeof pos !== "number" || !Number.isFinite(pos))
|
|
490
|
-
return;
|
|
491
|
-
setCurrentTime((prev) => (Math.abs(prev - pos) > 0.4 ? pos : prev));
|
|
492
|
-
} catch {
|
|
493
|
-
// player not ready yet — ignore
|
|
494
|
-
}
|
|
495
|
-
}, 500);
|
|
496
|
-
return () => {
|
|
497
|
-
cancelled = true;
|
|
498
|
-
clearInterval(id);
|
|
499
|
-
};
|
|
500
|
-
}, [playing]);
|
|
501
|
-
|
|
502
|
-
useEffect(() => {
|
|
503
|
-
playingRef.current = playing;
|
|
504
|
-
}, [playing]);
|
|
505
|
-
|
|
506
|
-
useEffect(() => {
|
|
507
|
-
onErrorRef.current = onError;
|
|
508
|
-
onProgressRef.current = onProgress;
|
|
509
|
-
}, [onError, onProgress]);
|
|
510
|
-
|
|
511
|
-
// Web-only: react-native-video's web driver only maps a few DOM events
|
|
512
|
-
// (onLoad <-> durationchange, sparse onProgress) and calls `play()` on load,
|
|
513
|
-
// which browsers deny without a user gesture. Instead of fighting that, take
|
|
514
|
-
// over the raw <video> element directly: attach our own listeners, mirror the
|
|
515
|
-
// clock/duration/buffering/loading state from the element, keep the icon in
|
|
516
|
-
// sync with the real paused state, force-play whenever the UI wants playback
|
|
517
|
-
// but the element drifted to paused (autoplay-denial recovery), and apply
|
|
518
|
-
// volume/muted/fullscreen changes. Native stays on react-native-video.
|
|
519
|
-
useEffect(() => {
|
|
520
|
-
if (Platform.OS !== "web") return;
|
|
521
|
-
let cancelled = false;
|
|
522
|
-
let el: any = null;
|
|
523
|
-
let listeners: Array<[string, (ev?: unknown) => void]> = [];
|
|
524
|
-
|
|
525
|
-
const detach = (): void => {
|
|
526
|
-
if (!el) return;
|
|
527
|
-
for (const [type, fn] of listeners) {
|
|
528
|
-
try {
|
|
529
|
-
el.removeEventListener(type, fn);
|
|
530
|
-
} catch {}
|
|
531
|
-
}
|
|
532
|
-
listeners = [];
|
|
533
|
-
el = null;
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
const add = (type: string, fn: (ev?: unknown) => void): void => {
|
|
537
|
-
try {
|
|
538
|
-
el.addEventListener(type, fn);
|
|
539
|
-
listeners.push([type, fn]);
|
|
540
|
-
} catch {}
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
const attach = (): any => {
|
|
544
|
-
const next = (videoRef.current as any)?.nativeHtmlVideoRef?.current ?? null;
|
|
545
|
-
if (!next) return null;
|
|
546
|
-
if (next === el) return el;
|
|
547
|
-
detach();
|
|
548
|
-
el = next;
|
|
549
|
-
el.muted = mutedRef.current;
|
|
550
|
-
el.volume = Math.min(Math.max(volumeRef.current, 0), 1);
|
|
551
|
-
|
|
552
|
-
add("timeupdate", () => {
|
|
553
|
-
const t = typeof el.currentTime === "number" ? el.currentTime : 0;
|
|
554
|
-
setCurrentTime((prev) => (Math.abs(prev - t) > 0.25 ? t : prev));
|
|
555
|
-
const d = typeof el.duration === "number" ? el.duration : 0;
|
|
556
|
-
onProgressRef.current?.({
|
|
557
|
-
currentTime: t,
|
|
558
|
-
duration: Number.isFinite(d) ? d : 0,
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
add("durationchange", () => {
|
|
562
|
-
const d = typeof el.duration === "number" ? el.duration : 0;
|
|
563
|
-
if (typeof d === "number" && Number.isFinite(d) && d > 0) {
|
|
564
|
-
setDuration(d);
|
|
565
|
-
}
|
|
566
|
-
setLoading(false);
|
|
567
|
-
setBuffering(false);
|
|
568
|
-
});
|
|
569
|
-
add("play", () => {
|
|
570
|
-
setLoading(false);
|
|
571
|
-
setBuffering(false);
|
|
572
|
-
setPlaying(true);
|
|
573
|
-
});
|
|
574
|
-
add("pause", () => {
|
|
575
|
-
setBuffering(false);
|
|
576
|
-
setPlaying(false);
|
|
577
|
-
});
|
|
578
|
-
add("waiting", () => setBuffering(true));
|
|
579
|
-
add("canplay", () => {
|
|
580
|
-
setBuffering(false);
|
|
581
|
-
setLoading(false);
|
|
582
|
-
});
|
|
583
|
-
add("loadstart", () => setLoading(true));
|
|
584
|
-
add("error", () => {
|
|
585
|
-
const msg =
|
|
586
|
-
(el?.error && (el.error.message || `Video error ${el.error.code}`)) ||
|
|
587
|
-
"Video decode error";
|
|
588
|
-
console.warn("[MegaVideoPlayer] web element error:", msg);
|
|
589
|
-
setLoading(false);
|
|
590
|
-
setBuffering(false);
|
|
591
|
-
setError(msg);
|
|
592
|
-
onError?.(msg);
|
|
593
|
-
});
|
|
594
|
-
const doc = (globalThis as any)?.document ?? null;
|
|
595
|
-
if (doc) {
|
|
596
|
-
add("fullscreenchange", () => {
|
|
597
|
-
try {
|
|
598
|
-
setFullscreen(!!doc.fullscreenElement);
|
|
599
|
-
} catch {}
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
return el;
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
const id = setInterval(() => {
|
|
606
|
-
if (cancelled) return;
|
|
607
|
-
const elem = attach();
|
|
608
|
-
if (!elem) return;
|
|
609
|
-
try {
|
|
610
|
-
// Autoplay-deadlock breaker: the UI wants playback, but the browser
|
|
611
|
-
// held the element at pause (initial autoplay denied, or a temporary
|
|
612
|
-
// browser pause). Re-assert play every tick so a single user gesture
|
|
613
|
-
// anywhere in the app unblocks it; seek once we finally have a size.
|
|
614
|
-
if (playingRef.current && elem.paused && !elem.ended) {
|
|
615
|
-
setLoading(false);
|
|
616
|
-
el = elem;
|
|
617
|
-
const p = elem.play();
|
|
618
|
-
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
619
|
-
}
|
|
620
|
-
// Mirror the real state back so the icon can never lie about pause.
|
|
621
|
-
const real = elem.paused;
|
|
622
|
-
if (real && playingRef.current && elem.ended) {
|
|
623
|
-
setPlaying(false);
|
|
624
|
-
}
|
|
625
|
-
} catch {}
|
|
626
|
-
}, 500);
|
|
627
|
-
|
|
628
|
-
return () => {
|
|
629
|
-
cancelled = true;
|
|
630
|
-
clearInterval(id);
|
|
631
|
-
detach();
|
|
632
|
-
};
|
|
633
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
634
|
-
}, []);
|
|
635
|
-
|
|
636
|
-
// ---- render ----
|
|
637
|
-
|
|
638
|
-
const activeCue =
|
|
639
|
-
cues.find(
|
|
640
|
-
(c) =>
|
|
641
|
-
currentTime - (subtitleOffset ?? 0) >= c.start &&
|
|
642
|
-
currentTime - (subtitleOffset ?? 0) < c.end,
|
|
643
|
-
) ?? null;
|
|
644
|
-
const activeTrack =
|
|
645
|
-
tracks[Math.min(subTrackIdxRef.current, tracks.length - 1)] ?? null;
|
|
646
|
-
|
|
647
|
-
if (error) {
|
|
648
|
-
return (
|
|
649
|
-
<View
|
|
650
|
-
style={[
|
|
651
|
-
styles.wrap,
|
|
652
|
-
styles.center,
|
|
653
|
-
{
|
|
654
|
-
width: width ? width : "100%",
|
|
655
|
-
height: height ? height : "100%",
|
|
656
|
-
flexDirection: "column",
|
|
657
|
-
},
|
|
658
|
-
]}
|
|
659
|
-
>
|
|
660
|
-
<Ionicons name="alert-circle-outline" size={40} color="#ff6b6b" />
|
|
661
|
-
<Text style={styles.errorText}>{error}</Text>
|
|
662
|
-
<Text style={styles.errorHint}>Use "Download & Play" instead.</Text>
|
|
663
|
-
</View>
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
return (
|
|
668
|
-
<View
|
|
669
|
-
style={[
|
|
670
|
-
styles.wrap,
|
|
671
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
672
|
-
fullscreen && styles.fullscreen,
|
|
673
|
-
]}
|
|
674
|
-
>
|
|
675
|
-
{sourceUri ? (
|
|
676
|
-
<Video
|
|
677
|
-
ref={videoRef}
|
|
678
|
-
source={videoSource}
|
|
679
|
-
style={[
|
|
680
|
-
StyleSheet.absoluteFill,
|
|
681
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
682
|
-
]}
|
|
683
|
-
resizeMode="contain"
|
|
684
|
-
paused={!playing}
|
|
685
|
-
muted={muted}
|
|
686
|
-
volume={Platform.OS === "web" ? undefined : volume}
|
|
687
|
-
progressUpdateInterval={1000}
|
|
688
|
-
onLoadStart={() => {
|
|
689
|
-
if (mountedRef.current) setBuffering(true);
|
|
690
|
-
}}
|
|
691
|
-
onLoad={onVideoLoad}
|
|
692
|
-
onBuffer={({ isBuffering }: { isBuffering?: boolean }) => {
|
|
693
|
-
if (mountedRef.current) setBuffering(!!isBuffering);
|
|
694
|
-
}}
|
|
695
|
-
onProgress={onVideoProgress}
|
|
696
|
-
onPlaybackStateChanged={onVideoPlaybackStateChanged}
|
|
697
|
-
onError={onVideoError}
|
|
698
|
-
onEnd={onVideoEnd}
|
|
699
|
-
/>
|
|
700
|
-
) : null}
|
|
701
|
-
|
|
702
|
-
{loading && !buffering && (
|
|
703
|
-
<View
|
|
704
|
-
style={[
|
|
705
|
-
styles.centerBadge,
|
|
706
|
-
StyleSheet.absoluteFill,
|
|
707
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
708
|
-
]}
|
|
709
|
-
pointerEvents="none"
|
|
710
|
-
>
|
|
711
|
-
<ActivityIndicator color="#e50914" size="large" />
|
|
712
|
-
<Text style={styles.loadingText}>Starting stream…</Text>
|
|
713
|
-
</View>
|
|
714
|
-
)}
|
|
715
|
-
|
|
716
|
-
<SubtitleDisplayAndroid
|
|
717
|
-
cue={activeCue}
|
|
718
|
-
enabled={subtitlesOn}
|
|
719
|
-
fontSize={subFontSize}
|
|
720
|
-
width={width}
|
|
721
|
-
height={height}
|
|
722
|
-
/>
|
|
723
|
-
{subtitlesOn && subtitleError && !cues.length && (
|
|
724
|
-
<View pointerEvents="none" style={styles.subErrorWrap}>
|
|
725
|
-
<Text style={styles.subErrorText}>
|
|
726
|
-
{subtitleError} · {activeTrack?.lang ?? "no track"}
|
|
727
|
-
</Text>
|
|
728
|
-
</View>
|
|
729
|
-
)}
|
|
730
|
-
|
|
731
|
-
<PlayerControlsAndroid
|
|
732
|
-
playing={playing}
|
|
733
|
-
buffering={buffering}
|
|
734
|
-
currentTime={currentTime}
|
|
735
|
-
duration={duration}
|
|
736
|
-
muted={muted}
|
|
737
|
-
title={title}
|
|
738
|
-
visible={visible}
|
|
739
|
-
subtitlesOn={subtitlesOn}
|
|
740
|
-
volume={volume}
|
|
741
|
-
subtitleTracks={tracks.map((t) => t.lang)}
|
|
742
|
-
subtitleTrack={activeTrack?.lang}
|
|
743
|
-
onSelectSubtitleTrack={selectSubtitleTrack}
|
|
744
|
-
subtitleFontSize={subFontSize}
|
|
745
|
-
onChangeSubtitleFontSize={changeSubtitleFontSize}
|
|
746
|
-
onToggleVisible={toggleControls}
|
|
747
|
-
onPlayPause={playPause}
|
|
748
|
-
onSkip={skip}
|
|
749
|
-
onSeek={seek}
|
|
750
|
-
onToggleMute={toggleMute}
|
|
751
|
-
onToggleSubtitles={toggleSubtitles}
|
|
752
|
-
onVolume={changeVolume}
|
|
753
|
-
resolutions={resolutions}
|
|
754
|
-
resolution={resolution}
|
|
755
|
-
onSelectResolution={onSelectResolution}
|
|
756
|
-
onFullscreen={enterFullscreen}
|
|
757
|
-
fullscreen={fullscreen}
|
|
758
|
-
onInteract={onInteract}
|
|
759
|
-
width={width}
|
|
760
|
-
height={height}
|
|
761
|
-
/>
|
|
762
|
-
</View>
|
|
763
|
-
);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
const styles = StyleSheet.create({
|
|
767
|
-
wrap: {
|
|
768
|
-
flex: 1,
|
|
769
|
-
width: "100%",
|
|
770
|
-
height: "100%",
|
|
771
|
-
backgroundColor: "#000",
|
|
772
|
-
},
|
|
773
|
-
fullscreen: {
|
|
774
|
-
position: "absolute",
|
|
775
|
-
left: 0,
|
|
776
|
-
right: 0,
|
|
777
|
-
top: 0,
|
|
778
|
-
bottom: 0,
|
|
779
|
-
zIndex: 10,
|
|
780
|
-
},
|
|
781
|
-
center: {
|
|
782
|
-
flex: 1,
|
|
783
|
-
justifyContent: "center",
|
|
784
|
-
alignItems: "center",
|
|
785
|
-
gap: 12,
|
|
786
|
-
padding: 20,
|
|
787
|
-
backgroundColor: "#000",
|
|
788
|
-
},
|
|
789
|
-
errorText: {
|
|
790
|
-
color: "#ff6b6b",
|
|
791
|
-
fontSize: 14,
|
|
792
|
-
textAlign: "center",
|
|
793
|
-
},
|
|
794
|
-
errorHint: {
|
|
795
|
-
color: "#9aa0aa",
|
|
796
|
-
fontSize: 12,
|
|
797
|
-
textAlign: "center",
|
|
798
|
-
},
|
|
799
|
-
centerBadge: {
|
|
800
|
-
|
|
801
|
-
justifyContent: "center",
|
|
802
|
-
alignItems: "center",
|
|
803
|
-
gap: 12,
|
|
804
|
-
},
|
|
805
|
-
loadingText: { color: "#fff", fontSize: 14 },
|
|
806
|
-
subErrorWrap: {
|
|
807
|
-
position: "absolute",
|
|
808
|
-
left: 0,
|
|
809
|
-
right: 0,
|
|
810
|
-
bottom: 56,
|
|
811
|
-
alignItems: "center",
|
|
812
|
-
paddingHorizontal: 24,
|
|
813
|
-
zIndex: 5,
|
|
814
|
-
},
|
|
815
|
-
subErrorText: {
|
|
816
|
-
backgroundColor: "rgba(229,9,20,0.8)",
|
|
817
|
-
color: "#fff",
|
|
818
|
-
fontSize: 12,
|
|
819
|
-
textAlign: "center",
|
|
820
|
-
paddingHorizontal: 10,
|
|
821
|
-
paddingVertical: 4,
|
|
822
|
-
borderRadius: 4,
|
|
823
|
-
overflow: "hidden",
|
|
824
|
-
},
|
|
825
|
-
});
|