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
package/src/proxy/native.ts
CHANGED
|
@@ -272,13 +272,20 @@ function handleRequest(socket: TcpSocketLike, req: ParsedRequest): void {
|
|
|
272
272
|
console.warn(`[megaStream] unexpected method ${req.method} — serving as GET`);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
const [sessionId,
|
|
275
|
+
const [sessionId, encodedName] = req.target.replace(/^\//, "").split(/\?|#/)[0].split("/", 2);
|
|
276
276
|
if (!sessionId) return serveError(socket, 404, "Not Found");
|
|
277
277
|
|
|
278
278
|
const session = sessions.get(sessionId);
|
|
279
279
|
if (!session) return serveError(socket, 404, `No stream session ${sessionId}`);
|
|
280
280
|
|
|
281
|
-
if (
|
|
281
|
+
if (encodedName) {
|
|
282
|
+
// Clients send the URL-encoded name on the wire (e.g. "My%20Video.mp4").
|
|
283
|
+
let name: string;
|
|
284
|
+
try {
|
|
285
|
+
name = decodeURIComponent(encodedName);
|
|
286
|
+
} catch {
|
|
287
|
+
name = encodedName; // not valid encoding — match literally
|
|
288
|
+
}
|
|
282
289
|
const part = session.parts.find((p) => p.name === name);
|
|
283
290
|
if (!part) return serveError(socket, 404, `Not Found: ${name}`);
|
|
284
291
|
return serveSingleFile(socket, req, part.file, part.size);
|
|
@@ -339,7 +346,7 @@ export async function createStream(megaUrl: string): Promise<{ url: string; sess
|
|
|
339
346
|
const name = file.name || "stream.mp4";
|
|
340
347
|
sessions.set(sessionId, { parts: [{ name, file, size: file.size }] });
|
|
341
348
|
|
|
342
|
-
const url = `${proxyBase}/${sessionId}/${name}`;
|
|
349
|
+
const url = `${proxyBase}/${sessionId}/${encodeURIComponent(name)}`;
|
|
343
350
|
console.log(`[megaStream] session ${sessionId}: serving '${name}' via ${url}`);
|
|
344
351
|
return { url, sessionId };
|
|
345
352
|
}
|
package/dist/Player/native.d.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
export { MegaVideoPlayerAndroid, type MegaVideoPlayerAndroidProps, type MegaVideoPlayerAndroidHandle, } from "./playerAndroid/MegaVideoPlayerAndroid.js";
|
|
2
|
-
export { PlayerControlsAndroid, type PlayerControlsAndroidProps } from "./playerAndroid/PlayerControlsAndroid.js";
|
|
3
|
-
export { SubtitleDisplayAndroid, type SubCueShapeAndroid } from "./playerAndroid/SubtitleDisplayAndroid.js";
|
package/dist/Player/native.js
DELETED
|
@@ -1,624 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// The exact MyMovies episode player (components/MegaVideoPlayer.web.tsx),
|
|
3
|
-
// packaged as a cross-platform component (Android / iOS / web). Driven by the
|
|
4
|
-
// react-native-video `<Video>` component, with ordered part URLs played
|
|
5
|
-
// back-to-back and auto-advancing to the next part when the current one ends.
|
|
6
|
-
// Sources come in as proxy URLs — from `mega-stream-proxy/native`
|
|
7
|
-
// (http://127.0.0.1:<port>/…) on Android/iOS or from the Service Worker
|
|
8
|
-
// (`/__mmstream__/…`) on the web.
|
|
9
|
-
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, } from "react";
|
|
10
|
-
import { ActivityIndicator, Platform, StyleSheet, Text, View, } from "react-native";
|
|
11
|
-
import { Video } from "react-native-video";
|
|
12
|
-
import { Ionicons } from "@expo/vector-icons";
|
|
13
|
-
import { PlayerControlsAndroid } from "./PlayerControlsAndroid.js";
|
|
14
|
-
import { SubtitleDisplayAndroid } from "./SubtitleDisplayAndroid.js";
|
|
15
|
-
import { loadSubtitles, } from "../../subtitles/index.js";
|
|
16
|
-
export const MegaVideoPlayerAndroid = forwardRef(function MegaVideoPlayer({ uri, sources, title, subtitleUrl, subtitleFormat, subtitleLang, subtitleTracks: subtitleTracksProp, initialSubtitleLang, subtitleOffset, resolutions, resolution, onSelectResolution, autoPlay = true, onEnd, onError, onProgress, onFullscreenChange, width, height, }, ref) {
|
|
17
|
-
const [playing, setPlaying] = useState(false);
|
|
18
|
-
const [buffering, setBuffering] = useState(true);
|
|
19
|
-
const [loading, setLoading] = useState(false);
|
|
20
|
-
const [currentTime, setCurrentTime] = useState(0);
|
|
21
|
-
const [duration, setDuration] = useState(0);
|
|
22
|
-
const [muted, setMuted] = useState(false);
|
|
23
|
-
const [volume, setVolume] = useState(1);
|
|
24
|
-
const [visible, setVisible] = useState(true);
|
|
25
|
-
const [subtitlesOn, setSubtitlesOn] = useState(true);
|
|
26
|
-
const [subFontSize, setSubFontSize] = useState(14);
|
|
27
|
-
const [cues, setCues] = useState([]);
|
|
28
|
-
const [subtitleError, setSubtitleError] = useState(null);
|
|
29
|
-
const [error, setError] = useState(null);
|
|
30
|
-
const [fullscreen, setFullscreen] = useState(false);
|
|
31
|
-
const [controlsBump, setControlsBump] = useState(0);
|
|
32
|
-
const [sourceUri, setSourceUri] = useState(null);
|
|
33
|
-
const [tracks, setTracks] = useState([]);
|
|
34
|
-
// Keep the `<Video>` source object identity stable — a fresh `{ uri }` on
|
|
35
|
-
// every render makes the native player re-evaluate (and on some platforms
|
|
36
|
-
// reload) the source, which resets the playback clock.
|
|
37
|
-
const videoSource = useMemo(() => (sourceUri ? { uri: sourceUri } : undefined), [sourceUri]);
|
|
38
|
-
const videoRef = React.useRef(null);
|
|
39
|
-
const mountedRef = React.useRef(true);
|
|
40
|
-
const autoplayRef = React.useRef(autoPlay !== false);
|
|
41
|
-
const hideTimerRef = React.useRef(null);
|
|
42
|
-
const subTrackIdxRef = React.useRef(0);
|
|
43
|
-
const sourceIndexRef = React.useRef(Math.max(0, (sources ?? []).indexOf(uri ?? "")));
|
|
44
|
-
/** sources that auto-advance on end. Quality-switch files are full merged
|
|
45
|
-
* sources, so they stop part auto-advance. */
|
|
46
|
-
const activeSourcesRef = React.useRef(sources ?? []);
|
|
47
|
-
/** position to resume a quality switch at, applied once playback is moving */
|
|
48
|
-
const pendingResumeRef = React.useRef(null);
|
|
49
|
-
const prevResolutionRef = React.useRef(resolution);
|
|
50
|
-
/** web-only: the UI's intended play state + element volumes, so the DOM
|
|
51
|
-
* bridge can operate the raw <video> element without React re-renders. */
|
|
52
|
-
const playingRef = React.useRef(false);
|
|
53
|
-
const volumeRef = React.useRef(volume);
|
|
54
|
-
const mutedRef = React.useRef(muted);
|
|
55
|
-
const onErrorRef = React.useRef(onError);
|
|
56
|
-
const onProgressRef = React.useRef(onProgress);
|
|
57
|
-
// ---- source management ----
|
|
58
|
-
async function add(source, opts) {
|
|
59
|
-
if (!source)
|
|
60
|
-
return;
|
|
61
|
-
setError(null);
|
|
62
|
-
setLoading(true);
|
|
63
|
-
setBuffering(true);
|
|
64
|
-
setSourceUri(source);
|
|
65
|
-
setCurrentTime(0);
|
|
66
|
-
setDuration(0);
|
|
67
|
-
if (!mountedRef.current)
|
|
68
|
-
return;
|
|
69
|
-
console.log(`[MegaVideoPlayer] add: ${source} (${opts?.title ?? title})`);
|
|
70
|
-
if (autoplayRef.current) {
|
|
71
|
-
setPlaying(true);
|
|
72
|
-
scheduleHide();
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function stop() {
|
|
76
|
-
setSourceUri(null);
|
|
77
|
-
setPlaying(false);
|
|
78
|
-
}
|
|
79
|
-
useImperativeHandle(ref, () => ({ add, stop }), [add, stop]);
|
|
80
|
-
// ---- player controllers ----
|
|
81
|
-
const playPause = () => {
|
|
82
|
-
setPlaying((p) => !p);
|
|
83
|
-
setVisible(true);
|
|
84
|
-
if (Platform.OS === "web") {
|
|
85
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
86
|
-
if (el) {
|
|
87
|
-
try {
|
|
88
|
-
if (playing) {
|
|
89
|
-
el.pause();
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
setLoading(false);
|
|
93
|
-
const p = el.play();
|
|
94
|
-
if (p && typeof p.catch === "function")
|
|
95
|
-
p.catch(() => { });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch { }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
const skip = (delta) => {
|
|
103
|
-
const next = Math.min(Math.max(currentTime + delta, 0), duration);
|
|
104
|
-
if (Platform.OS === "web") {
|
|
105
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
106
|
-
if (el) {
|
|
107
|
-
try {
|
|
108
|
-
el.currentTime = next;
|
|
109
|
-
}
|
|
110
|
-
catch { }
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
videoRef.current?.seek(next);
|
|
115
|
-
}
|
|
116
|
-
setCurrentTime(next);
|
|
117
|
-
setVisible(true);
|
|
118
|
-
scheduleHide();
|
|
119
|
-
};
|
|
120
|
-
const seek = (seconds) => {
|
|
121
|
-
const clamped = Math.min(Math.max(seconds, 0), duration || seconds);
|
|
122
|
-
if (Platform.OS === "web") {
|
|
123
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
124
|
-
if (el) {
|
|
125
|
-
try {
|
|
126
|
-
el.currentTime = clamped;
|
|
127
|
-
}
|
|
128
|
-
catch { }
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
videoRef.current?.seek(clamped);
|
|
133
|
-
}
|
|
134
|
-
setCurrentTime(clamped);
|
|
135
|
-
setVisible(true);
|
|
136
|
-
scheduleHide();
|
|
137
|
-
};
|
|
138
|
-
const toggleMute = () => {
|
|
139
|
-
setMuted((m) => {
|
|
140
|
-
const next = !m;
|
|
141
|
-
mutedRef.current = next;
|
|
142
|
-
if (Platform.OS === "web") {
|
|
143
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
144
|
-
if (el) {
|
|
145
|
-
try {
|
|
146
|
-
el.muted = next;
|
|
147
|
-
}
|
|
148
|
-
catch { }
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return next;
|
|
152
|
-
});
|
|
153
|
-
setVisible(true);
|
|
154
|
-
};
|
|
155
|
-
const changeVolume = (v) => {
|
|
156
|
-
const clamped = Math.min(Math.max(v, 0), 1);
|
|
157
|
-
volumeRef.current = clamped;
|
|
158
|
-
setVolume(clamped);
|
|
159
|
-
if (clamped > 0)
|
|
160
|
-
setMuted(false);
|
|
161
|
-
if (Platform.OS === "web") {
|
|
162
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
163
|
-
if (el) {
|
|
164
|
-
try {
|
|
165
|
-
el.volume = clamped;
|
|
166
|
-
}
|
|
167
|
-
catch { }
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
const toggleSubtitles = () => {
|
|
172
|
-
setSubtitlesOn((s) => !s);
|
|
173
|
-
};
|
|
174
|
-
const toggleControls = () => {
|
|
175
|
-
setVisible((v) => !v);
|
|
176
|
-
if (!visible && playing)
|
|
177
|
-
scheduleHide();
|
|
178
|
-
};
|
|
179
|
-
/** any interaction with the control bar — reset the auto-hide timer */
|
|
180
|
-
const onInteract = () => {
|
|
181
|
-
setControlsBump((n) => n + 1);
|
|
182
|
-
};
|
|
183
|
-
const enterFullscreen = (next) => {
|
|
184
|
-
// PlayerControls calls through onPress, which passes the press event as
|
|
185
|
-
// the first argument — ignore it (it is not the desired fullscreen value).
|
|
186
|
-
const target = typeof next === "boolean" ? next : !fullscreen;
|
|
187
|
-
setFullscreen(target);
|
|
188
|
-
setVisible(true);
|
|
189
|
-
onFullscreenChange?.(target);
|
|
190
|
-
if (Platform.OS === "web") {
|
|
191
|
-
const doc = globalThis?.document ?? null;
|
|
192
|
-
const el = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
193
|
-
try {
|
|
194
|
-
if (target) {
|
|
195
|
-
el?.requestFullscreen?.();
|
|
196
|
-
}
|
|
197
|
-
else if (doc?.fullscreenElement) {
|
|
198
|
-
doc.exitFullscreen();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
catch { }
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
const changeSubtitleFontSize = (px) => {
|
|
205
|
-
setSubFontSize(px);
|
|
206
|
-
};
|
|
207
|
-
const selectSubtitleTrack = (lang) => {
|
|
208
|
-
const idx = tracks.findIndex((t) => t.lang === lang);
|
|
209
|
-
if (idx >= 0) {
|
|
210
|
-
subTrackIdxRef.current = idx;
|
|
211
|
-
setSubtitlesOn(true);
|
|
212
|
-
loadCues(tracks);
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
// ---- react-native-video handlers ----
|
|
216
|
-
const onVideoLoad = (data) => {
|
|
217
|
-
const d = data.duration ?? duration;
|
|
218
|
-
setDuration(d);
|
|
219
|
-
setBuffering(false);
|
|
220
|
-
setLoading(false);
|
|
221
|
-
console.log(`[MegaVideoPlayer] onLoad duration=${d}`);
|
|
222
|
-
};
|
|
223
|
-
const onVideoProgress = (data) => {
|
|
224
|
-
const t = data.currentTime ?? 0;
|
|
225
|
-
// Always move the clock first — never let a pending quality-switch resume
|
|
226
|
-
// swallow progress events.
|
|
227
|
-
setCurrentTime(t);
|
|
228
|
-
onProgress?.({ currentTime: t, duration });
|
|
229
|
-
const pending = pendingResumeRef.current;
|
|
230
|
-
// quality switch: resume the previous position once the new stream is
|
|
231
|
-
// actually flowing (t > 0) — seeking too early can stall the load.
|
|
232
|
-
if (pending != null && t > 0 && duration > 0) {
|
|
233
|
-
pendingResumeRef.current = null;
|
|
234
|
-
const target = duration > pending + 20 ? pending : Math.max(duration - 20, 0);
|
|
235
|
-
videoRef.current?.seek(target);
|
|
236
|
-
setCurrentTime(target);
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
const onVideoPlaybackStateChanged = (data) => {
|
|
240
|
-
if (!mountedRef.current)
|
|
241
|
-
return;
|
|
242
|
-
// Web only: browsers may deny autoplay, so mirror the real element state
|
|
243
|
-
// back into the UI. On native, `isPlaying` drops to false while buffering,
|
|
244
|
-
// which must NOT be interpreted as a user pause.
|
|
245
|
-
if (Platform.OS !== "web")
|
|
246
|
-
return;
|
|
247
|
-
if (typeof data?.isPlaying === "boolean") {
|
|
248
|
-
setPlaying((prev) => (prev === data.isPlaying ? prev : data.isPlaying));
|
|
249
|
-
if (data.isPlaying) {
|
|
250
|
-
setBuffering(false);
|
|
251
|
-
setLoading(false);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
const onVideoError = (e) => {
|
|
256
|
-
if (!mountedRef.current)
|
|
257
|
-
return;
|
|
258
|
-
const message = e?.error?.message || e?.error?.errorString || String(e) || "Video error";
|
|
259
|
-
console.warn("[MegaVideoPlayer] onError:", message);
|
|
260
|
-
setError(message);
|
|
261
|
-
setBuffering(false);
|
|
262
|
-
setLoading(false);
|
|
263
|
-
onError?.(message);
|
|
264
|
-
};
|
|
265
|
-
const onVideoEnd = () => {
|
|
266
|
-
const list = activeSourcesRef.current;
|
|
267
|
-
const next = list[sourceIndexRef.current + 1];
|
|
268
|
-
if (next) {
|
|
269
|
-
sourceIndexRef.current += 1;
|
|
270
|
-
console.log(`[MegaVideoPlayer] advancing to part ${sourceIndexRef.current + 1}/${list.length}`);
|
|
271
|
-
void add(next, { title });
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
setPlaying(false);
|
|
275
|
-
setVisible(true);
|
|
276
|
-
onEnd?.();
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
// ---- helpers ----
|
|
280
|
-
function scheduleHide() {
|
|
281
|
-
if (hideTimerRef.current)
|
|
282
|
-
clearTimeout(hideTimerRef.current);
|
|
283
|
-
hideTimerRef.current = setTimeout(() => {
|
|
284
|
-
setPlaying((p) => {
|
|
285
|
-
if (p)
|
|
286
|
-
setVisible(false);
|
|
287
|
-
return p;
|
|
288
|
-
});
|
|
289
|
-
}, 3000);
|
|
290
|
-
}
|
|
291
|
-
function loadCues(trackList = tracks) {
|
|
292
|
-
const track = trackList[Math.min(subTrackIdxRef.current, trackList.length - 1)] ?? null;
|
|
293
|
-
setCues([]);
|
|
294
|
-
setSubtitleError(null);
|
|
295
|
-
if (!track)
|
|
296
|
-
return;
|
|
297
|
-
loadSubtitles(track.url, { format: track.format ?? undefined })
|
|
298
|
-
.then((c) => {
|
|
299
|
-
if (!mountedRef.current)
|
|
300
|
-
return;
|
|
301
|
-
setCues(c);
|
|
302
|
-
setSubtitleError(c.length === 0 ? "No cues parsed from this subtitle file" : null);
|
|
303
|
-
})
|
|
304
|
-
.catch((e) => {
|
|
305
|
-
console.warn("[subtitles] load failed:", track.url, e);
|
|
306
|
-
if (mountedRef.current)
|
|
307
|
-
setSubtitleError("Subtitles unavailable");
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
function syncSubtitleTracks() {
|
|
311
|
-
const nextTracks = subtitleTracksProp?.length
|
|
312
|
-
? subtitleTracksProp
|
|
313
|
-
: subtitleUrl
|
|
314
|
-
? [
|
|
315
|
-
{
|
|
316
|
-
lang: subtitleLang ?? "SRT",
|
|
317
|
-
url: subtitleUrl,
|
|
318
|
-
format: subtitleFormat,
|
|
319
|
-
},
|
|
320
|
-
]
|
|
321
|
-
: [];
|
|
322
|
-
setTracks(nextTracks);
|
|
323
|
-
// Preselect the requested track (from the watch screen's subtitle picker)
|
|
324
|
-
if (initialSubtitleLang) {
|
|
325
|
-
const idx = nextTracks.findIndex((t) => t.lang === initialSubtitleLang);
|
|
326
|
-
if (idx >= 0)
|
|
327
|
-
subTrackIdxRef.current = idx;
|
|
328
|
-
}
|
|
329
|
-
loadCues(nextTracks);
|
|
330
|
-
}
|
|
331
|
-
// ---- effects ----
|
|
332
|
-
useEffect(() => {
|
|
333
|
-
mountedRef.current = true;
|
|
334
|
-
autoplayRef.current = autoPlay !== false;
|
|
335
|
-
return () => {
|
|
336
|
-
mountedRef.current = false;
|
|
337
|
-
if (hideTimerRef.current)
|
|
338
|
-
clearTimeout(hideTimerRef.current);
|
|
339
|
-
};
|
|
340
|
-
}, [autoPlay]);
|
|
341
|
-
useEffect(() => {
|
|
342
|
-
if (!uri)
|
|
343
|
-
return;
|
|
344
|
-
sourceIndexRef.current = Math.max(0, (sources ?? []).indexOf(uri));
|
|
345
|
-
const prevRes = prevResolutionRef.current;
|
|
346
|
-
const qualitySwitch = resolution != null && resolution !== prevRes;
|
|
347
|
-
prevResolutionRef.current = resolution;
|
|
348
|
-
if (qualitySwitch) {
|
|
349
|
-
pendingResumeRef.current = currentTime;
|
|
350
|
-
const isDefaultChain = (sources ?? []).includes(uri ?? "");
|
|
351
|
-
// a resolution file is one merged source — don't auto-advance through it
|
|
352
|
-
activeSourcesRef.current = isDefaultChain ? (sources ?? []) : [];
|
|
353
|
-
}
|
|
354
|
-
else {
|
|
355
|
-
pendingResumeRef.current = null;
|
|
356
|
-
activeSourcesRef.current = sources ?? [];
|
|
357
|
-
}
|
|
358
|
-
void add(uri, { title });
|
|
359
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
360
|
-
}, [uri]);
|
|
361
|
-
useEffect(() => {
|
|
362
|
-
activeSourcesRef.current = sources ?? [];
|
|
363
|
-
}, [sources]);
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
syncSubtitleTracks();
|
|
366
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
-
}, [subtitleTracksProp, subtitleUrl]);
|
|
368
|
-
useEffect(() => {
|
|
369
|
-
if (!visible || !playing)
|
|
370
|
-
return;
|
|
371
|
-
const t = setTimeout(() => setVisible(false), 3000);
|
|
372
|
-
return () => clearTimeout(t);
|
|
373
|
-
}, [visible, playing, controlsBump]);
|
|
374
|
-
// Safety net for the playback clock: some platforms/builds deliver
|
|
375
|
-
// `onProgress` sparsely. While playing, poll the player position directly so
|
|
376
|
-
// the timer, scrubber and subtitles keep advancing.
|
|
377
|
-
useEffect(() => {
|
|
378
|
-
if (!playing)
|
|
379
|
-
return;
|
|
380
|
-
let cancelled = false;
|
|
381
|
-
const id = setInterval(async () => {
|
|
382
|
-
try {
|
|
383
|
-
const pos = await videoRef.current?.getCurrentPosition?.();
|
|
384
|
-
if (cancelled || typeof pos !== "number" || !Number.isFinite(pos))
|
|
385
|
-
return;
|
|
386
|
-
setCurrentTime((prev) => (Math.abs(prev - pos) > 0.4 ? pos : prev));
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
// player not ready yet — ignore
|
|
390
|
-
}
|
|
391
|
-
}, 500);
|
|
392
|
-
return () => {
|
|
393
|
-
cancelled = true;
|
|
394
|
-
clearInterval(id);
|
|
395
|
-
};
|
|
396
|
-
}, [playing]);
|
|
397
|
-
useEffect(() => {
|
|
398
|
-
playingRef.current = playing;
|
|
399
|
-
}, [playing]);
|
|
400
|
-
useEffect(() => {
|
|
401
|
-
onErrorRef.current = onError;
|
|
402
|
-
onProgressRef.current = onProgress;
|
|
403
|
-
}, [onError, onProgress]);
|
|
404
|
-
// Web-only: react-native-video's web driver only maps a few DOM events
|
|
405
|
-
// (onLoad <-> durationchange, sparse onProgress) and calls `play()` on load,
|
|
406
|
-
// which browsers deny without a user gesture. Instead of fighting that, take
|
|
407
|
-
// over the raw <video> element directly: attach our own listeners, mirror the
|
|
408
|
-
// clock/duration/buffering/loading state from the element, keep the icon in
|
|
409
|
-
// sync with the real paused state, force-play whenever the UI wants playback
|
|
410
|
-
// but the element drifted to paused (autoplay-denial recovery), and apply
|
|
411
|
-
// volume/muted/fullscreen changes. Native stays on react-native-video.
|
|
412
|
-
useEffect(() => {
|
|
413
|
-
if (Platform.OS !== "web")
|
|
414
|
-
return;
|
|
415
|
-
let cancelled = false;
|
|
416
|
-
let el = null;
|
|
417
|
-
let listeners = [];
|
|
418
|
-
const detach = () => {
|
|
419
|
-
if (!el)
|
|
420
|
-
return;
|
|
421
|
-
for (const [type, fn] of listeners) {
|
|
422
|
-
try {
|
|
423
|
-
el.removeEventListener(type, fn);
|
|
424
|
-
}
|
|
425
|
-
catch { }
|
|
426
|
-
}
|
|
427
|
-
listeners = [];
|
|
428
|
-
el = null;
|
|
429
|
-
};
|
|
430
|
-
const add = (type, fn) => {
|
|
431
|
-
try {
|
|
432
|
-
el.addEventListener(type, fn);
|
|
433
|
-
listeners.push([type, fn]);
|
|
434
|
-
}
|
|
435
|
-
catch { }
|
|
436
|
-
};
|
|
437
|
-
const attach = () => {
|
|
438
|
-
const next = videoRef.current?.nativeHtmlVideoRef?.current ?? null;
|
|
439
|
-
if (!next)
|
|
440
|
-
return null;
|
|
441
|
-
if (next === el)
|
|
442
|
-
return el;
|
|
443
|
-
detach();
|
|
444
|
-
el = next;
|
|
445
|
-
el.muted = mutedRef.current;
|
|
446
|
-
el.volume = Math.min(Math.max(volumeRef.current, 0), 1);
|
|
447
|
-
add("timeupdate", () => {
|
|
448
|
-
const t = typeof el.currentTime === "number" ? el.currentTime : 0;
|
|
449
|
-
setCurrentTime((prev) => (Math.abs(prev - t) > 0.25 ? t : prev));
|
|
450
|
-
const d = typeof el.duration === "number" ? el.duration : 0;
|
|
451
|
-
onProgressRef.current?.({
|
|
452
|
-
currentTime: t,
|
|
453
|
-
duration: Number.isFinite(d) ? d : 0,
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
add("durationchange", () => {
|
|
457
|
-
const d = typeof el.duration === "number" ? el.duration : 0;
|
|
458
|
-
if (typeof d === "number" && Number.isFinite(d) && d > 0) {
|
|
459
|
-
setDuration(d);
|
|
460
|
-
}
|
|
461
|
-
setLoading(false);
|
|
462
|
-
setBuffering(false);
|
|
463
|
-
});
|
|
464
|
-
add("play", () => {
|
|
465
|
-
setLoading(false);
|
|
466
|
-
setBuffering(false);
|
|
467
|
-
setPlaying(true);
|
|
468
|
-
});
|
|
469
|
-
add("pause", () => {
|
|
470
|
-
setBuffering(false);
|
|
471
|
-
setPlaying(false);
|
|
472
|
-
});
|
|
473
|
-
add("waiting", () => setBuffering(true));
|
|
474
|
-
add("canplay", () => {
|
|
475
|
-
setBuffering(false);
|
|
476
|
-
setLoading(false);
|
|
477
|
-
});
|
|
478
|
-
add("loadstart", () => setLoading(true));
|
|
479
|
-
add("error", () => {
|
|
480
|
-
const msg = (el?.error && (el.error.message || `Video error ${el.error.code}`)) ||
|
|
481
|
-
"Video decode error";
|
|
482
|
-
console.warn("[MegaVideoPlayer] web element error:", msg);
|
|
483
|
-
setLoading(false);
|
|
484
|
-
setBuffering(false);
|
|
485
|
-
setError(msg);
|
|
486
|
-
onError?.(msg);
|
|
487
|
-
});
|
|
488
|
-
const doc = globalThis?.document ?? null;
|
|
489
|
-
if (doc) {
|
|
490
|
-
add("fullscreenchange", () => {
|
|
491
|
-
try {
|
|
492
|
-
setFullscreen(!!doc.fullscreenElement);
|
|
493
|
-
}
|
|
494
|
-
catch { }
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
return el;
|
|
498
|
-
};
|
|
499
|
-
const id = setInterval(() => {
|
|
500
|
-
if (cancelled)
|
|
501
|
-
return;
|
|
502
|
-
const elem = attach();
|
|
503
|
-
if (!elem)
|
|
504
|
-
return;
|
|
505
|
-
try {
|
|
506
|
-
// Autoplay-deadlock breaker: the UI wants playback, but the browser
|
|
507
|
-
// held the element at pause (initial autoplay denied, or a temporary
|
|
508
|
-
// browser pause). Re-assert play every tick so a single user gesture
|
|
509
|
-
// anywhere in the app unblocks it; seek once we finally have a size.
|
|
510
|
-
if (playingRef.current && elem.paused && !elem.ended) {
|
|
511
|
-
setLoading(false);
|
|
512
|
-
el = elem;
|
|
513
|
-
const p = elem.play();
|
|
514
|
-
if (p && typeof p.catch === "function")
|
|
515
|
-
p.catch(() => { });
|
|
516
|
-
}
|
|
517
|
-
// Mirror the real state back so the icon can never lie about pause.
|
|
518
|
-
const real = elem.paused;
|
|
519
|
-
if (real && playingRef.current && elem.ended) {
|
|
520
|
-
setPlaying(false);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
catch { }
|
|
524
|
-
}, 500);
|
|
525
|
-
return () => {
|
|
526
|
-
cancelled = true;
|
|
527
|
-
clearInterval(id);
|
|
528
|
-
detach();
|
|
529
|
-
};
|
|
530
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
531
|
-
}, []);
|
|
532
|
-
// ---- render ----
|
|
533
|
-
const activeCue = cues.find((c) => currentTime - (subtitleOffset ?? 0) >= c.start &&
|
|
534
|
-
currentTime - (subtitleOffset ?? 0) < c.end) ?? null;
|
|
535
|
-
const activeTrack = tracks[Math.min(subTrackIdxRef.current, tracks.length - 1)] ?? null;
|
|
536
|
-
if (error) {
|
|
537
|
-
return (_jsxs(View, { style: [
|
|
538
|
-
styles.wrap,
|
|
539
|
-
styles.center,
|
|
540
|
-
{
|
|
541
|
-
width: width ? width : "100%",
|
|
542
|
-
height: height ? height : "100%",
|
|
543
|
-
flexDirection: "column",
|
|
544
|
-
},
|
|
545
|
-
], children: [_jsx(Ionicons, { name: "alert-circle-outline", size: 40, color: "#ff6b6b" }), _jsx(Text, { style: styles.errorText, children: error }), _jsx(Text, { style: styles.errorHint, children: "Use \"Download & Play\" instead." })] }));
|
|
546
|
-
}
|
|
547
|
-
return (_jsxs(View, { style: [
|
|
548
|
-
styles.wrap,
|
|
549
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
550
|
-
fullscreen && styles.fullscreen,
|
|
551
|
-
], children: [sourceUri ? (_jsx(Video, { ref: videoRef, source: videoSource, style: [
|
|
552
|
-
StyleSheet.absoluteFill,
|
|
553
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
554
|
-
], resizeMode: "contain", paused: !playing, muted: muted, volume: Platform.OS === "web" ? undefined : volume, progressUpdateInterval: 1000, onLoadStart: () => {
|
|
555
|
-
if (mountedRef.current)
|
|
556
|
-
setBuffering(true);
|
|
557
|
-
}, onLoad: onVideoLoad, onBuffer: ({ isBuffering }) => {
|
|
558
|
-
if (mountedRef.current)
|
|
559
|
-
setBuffering(!!isBuffering);
|
|
560
|
-
}, onProgress: onVideoProgress, onPlaybackStateChanged: onVideoPlaybackStateChanged, onError: onVideoError, onEnd: onVideoEnd })) : null, loading && !buffering && (_jsxs(View, { style: [
|
|
561
|
-
styles.centerBadge,
|
|
562
|
-
StyleSheet.absoluteFill,
|
|
563
|
-
{ width: width ? width : "100%", height: height ? height : "100%" },
|
|
564
|
-
], pointerEvents: "none", children: [_jsx(ActivityIndicator, { color: "#e50914", size: "large" }), _jsx(Text, { style: styles.loadingText, children: "Starting stream\u2026" })] })), _jsx(SubtitleDisplayAndroid, { cue: activeCue, enabled: subtitlesOn, fontSize: subFontSize, width: width, height: height }), subtitlesOn && subtitleError && !cues.length && (_jsx(View, { pointerEvents: "none", style: styles.subErrorWrap, children: _jsxs(Text, { style: styles.subErrorText, children: [subtitleError, " \u00B7 ", activeTrack?.lang ?? "no track"] }) })), _jsx(PlayerControlsAndroid, { playing: playing, buffering: buffering, currentTime: currentTime, duration: duration, muted: muted, title: title, visible: visible, subtitlesOn: subtitlesOn, volume: volume, subtitleTracks: tracks.map((t) => t.lang), subtitleTrack: activeTrack?.lang, onSelectSubtitleTrack: selectSubtitleTrack, subtitleFontSize: subFontSize, onChangeSubtitleFontSize: changeSubtitleFontSize, onToggleVisible: toggleControls, onPlayPause: playPause, onSkip: skip, onSeek: seek, onToggleMute: toggleMute, onToggleSubtitles: toggleSubtitles, onVolume: changeVolume, resolutions: resolutions, resolution: resolution, onSelectResolution: onSelectResolution, onFullscreen: enterFullscreen, fullscreen: fullscreen, onInteract: onInteract, width: width, height: height })] }));
|
|
565
|
-
});
|
|
566
|
-
const styles = StyleSheet.create({
|
|
567
|
-
wrap: {
|
|
568
|
-
flex: 1,
|
|
569
|
-
width: "100%",
|
|
570
|
-
height: "100%",
|
|
571
|
-
backgroundColor: "#000",
|
|
572
|
-
},
|
|
573
|
-
fullscreen: {
|
|
574
|
-
position: "absolute",
|
|
575
|
-
left: 0,
|
|
576
|
-
right: 0,
|
|
577
|
-
top: 0,
|
|
578
|
-
bottom: 0,
|
|
579
|
-
zIndex: 10,
|
|
580
|
-
},
|
|
581
|
-
center: {
|
|
582
|
-
flex: 1,
|
|
583
|
-
justifyContent: "center",
|
|
584
|
-
alignItems: "center",
|
|
585
|
-
gap: 12,
|
|
586
|
-
padding: 20,
|
|
587
|
-
backgroundColor: "#000",
|
|
588
|
-
},
|
|
589
|
-
errorText: {
|
|
590
|
-
color: "#ff6b6b",
|
|
591
|
-
fontSize: 14,
|
|
592
|
-
textAlign: "center",
|
|
593
|
-
},
|
|
594
|
-
errorHint: {
|
|
595
|
-
color: "#9aa0aa",
|
|
596
|
-
fontSize: 12,
|
|
597
|
-
textAlign: "center",
|
|
598
|
-
},
|
|
599
|
-
centerBadge: {
|
|
600
|
-
justifyContent: "center",
|
|
601
|
-
alignItems: "center",
|
|
602
|
-
gap: 12,
|
|
603
|
-
},
|
|
604
|
-
loadingText: { color: "#fff", fontSize: 14 },
|
|
605
|
-
subErrorWrap: {
|
|
606
|
-
position: "absolute",
|
|
607
|
-
left: 0,
|
|
608
|
-
right: 0,
|
|
609
|
-
bottom: 56,
|
|
610
|
-
alignItems: "center",
|
|
611
|
-
paddingHorizontal: 24,
|
|
612
|
-
zIndex: 5,
|
|
613
|
-
},
|
|
614
|
-
subErrorText: {
|
|
615
|
-
backgroundColor: "rgba(229,9,20,0.8)",
|
|
616
|
-
color: "#fff",
|
|
617
|
-
fontSize: 12,
|
|
618
|
-
textAlign: "center",
|
|
619
|
-
paddingHorizontal: 10,
|
|
620
|
-
paddingVertical: 4,
|
|
621
|
-
borderRadius: 4,
|
|
622
|
-
overflow: "hidden",
|
|
623
|
-
},
|
|
624
|
-
});
|