mega-stream-proxy 0.1.9
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/LICENSE +21 -0
- package/README.md +518 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/dist/Player/native.d.ts +3 -0
- package/dist/Player/native.js +3 -0
- package/dist/Player/playerAndroid/MegaVideoPlayerAndroid.d.ts +47 -0
- package/dist/Player/playerAndroid/MegaVideoPlayerAndroid.js +624 -0
- package/dist/Player/playerAndroid/PlayerControlsAndroid.d.ts +54 -0
- package/dist/Player/playerAndroid/PlayerControlsAndroid.js +370 -0
- package/dist/Player/playerAndroid/SubtitleDisplayAndroid.d.ts +21 -0
- package/dist/Player/playerAndroid/SubtitleDisplayAndroid.js +55 -0
- package/dist/Player/playerAndroid/index.d.ts +3 -0
- package/dist/Player/playerAndroid/index.js +3 -0
- package/dist/Player/playerweb/MegaVideoPlayer.d.ts +92 -0
- package/dist/Player/playerweb/MegaVideoPlayer.js +368 -0
- package/dist/Player/playerweb/PlayerControls.d.ts +52 -0
- package/dist/Player/playerweb/PlayerControls.js +346 -0
- package/dist/Player/playerweb/SubtitleDisplay.d.ts +15 -0
- package/dist/Player/playerweb/SubtitleDisplay.js +32 -0
- package/dist/Player/playerweb/index.d.ts +3 -0
- package/dist/Player/playerweb/index.js +3 -0
- package/dist/Player/web.d.ts +3 -0
- package/dist/Player/web.js +3 -0
- package/dist/accounts/async-storage.d.ts +10 -0
- package/dist/accounts/async-storage.js +46 -0
- package/dist/accounts/index.d.ts +4 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/local-storage.d.ts +3 -0
- package/dist/accounts/local-storage.js +63 -0
- package/dist/accounts/manager.d.ts +39 -0
- package/dist/accounts/manager.js +149 -0
- package/dist/accounts/types.d.ts +35 -0
- package/dist/accounts/types.js +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/megaFile.d.ts +7 -0
- package/dist/megaFile.js +31 -0
- package/dist/proxy/native.d.ts +25 -0
- package/dist/proxy/native.js +336 -0
- package/dist/proxy/sw/bundle.d.ts +21 -0
- package/dist/proxy/sw/bundle.js +32 -0
- package/dist/proxy/sw/megajs.browser.js +27 -0
- package/dist/proxy/sw/sw.js +212 -0
- package/dist/proxy/types.d.ts +15 -0
- package/dist/proxy/types.js +2 -0
- package/dist/proxy/web.d.ts +24 -0
- package/dist/proxy/web.js +231 -0
- package/dist/ranges.d.ts +30 -0
- package/dist/ranges.js +63 -0
- package/dist/stream.d.ts +9 -0
- package/dist/stream.js +100 -0
- package/dist/subtitles/index.d.ts +9 -0
- package/dist/subtitles/index.js +2 -0
- package/dist/subtitles/load.d.ts +18 -0
- package/dist/subtitles/load.js +58 -0
- package/dist/subtitles/parse.d.ts +19 -0
- package/dist/subtitles/parse.js +95 -0
- package/dist/swDefine.d.ts +5 -0
- package/dist/swDefine.js +5 -0
- package/package.json +112 -0
- package/src/Player/playerAndroid/MegaVideoPlayerAndroid.tsx +825 -0
- package/src/Player/playerAndroid/PlayerControlsAndroid.tsx +824 -0
- package/src/Player/playerAndroid/SubtitleDisplayAndroid.tsx +80 -0
- package/src/Player/playerAndroid/index.ts +7 -0
- package/src/Player/playerweb/MegaVideoPlayer.tsx +551 -0
- package/src/Player/playerweb/PlayerControls.tsx +798 -0
- package/src/Player/playerweb/SubtitleDisplay.tsx +48 -0
- package/src/Player/playerweb/index.ts +3 -0
- package/src/accounts/async-storage.ts +55 -0
- package/src/accounts/index.ts +9 -0
- package/src/accounts/local-storage.ts +66 -0
- package/src/accounts/manager.ts +174 -0
- package/src/accounts/types.ts +38 -0
- package/src/index.ts +10 -0
- package/src/megaFile.ts +48 -0
- package/src/proxy/native.ts +396 -0
- package/src/proxy/sw/bundle.ts +37 -0
- package/src/proxy/sw/megajs.browser.js +27 -0
- package/src/proxy/sw/sw.js +212 -0
- package/src/proxy/types.ts +17 -0
- package/src/proxy/web.ts +266 -0
- package/src/ranges.ts +72 -0
- package/src/stream.ts +117 -0
- package/src/subtitles/index.ts +15 -0
- package/src/subtitles/load.ts +83 -0
- package/src/subtitles/parse.ts +105 -0
- package/src/swDefine.ts +5 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
2
|
+
|
|
3
|
+
export type SubCueShapeAndroid = { start: number; end: number; text: string };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders the active subtitle cue over the video surface. Positioned
|
|
7
|
+
* absolutely with zIndex 1 (below the controls at zIndex 2, above the
|
|
8
|
+
* video background at zIndex 0).
|
|
9
|
+
*
|
|
10
|
+
* When `width`/`height` are supplied they are used to size the overlay to the
|
|
11
|
+
* exact area the video occupies, so cues stay aligned with the picture (e.g.
|
|
12
|
+
* when the player is letterboxed inside a wider container).
|
|
13
|
+
*/
|
|
14
|
+
export function SubtitleDisplayAndroid({
|
|
15
|
+
cue,
|
|
16
|
+
enabled,
|
|
17
|
+
fontSize,
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
}: {
|
|
21
|
+
cue: SubCueShapeAndroid | null;
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
fontSize: number;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
}) {
|
|
27
|
+
if (!enabled || !cue?.text) return null;
|
|
28
|
+
const sized = width != null || height != null;
|
|
29
|
+
return (
|
|
30
|
+
<View
|
|
31
|
+
pointerEvents="none"
|
|
32
|
+
style={
|
|
33
|
+
sized
|
|
34
|
+
? [
|
|
35
|
+
styles.videoArea,
|
|
36
|
+
{
|
|
37
|
+
width: width ?? "100%",
|
|
38
|
+
height: height ?? "100%",
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
: styles.wrap
|
|
42
|
+
}
|
|
43
|
+
>
|
|
44
|
+
<Text style={[styles.text, { fontSize, lineHeight: fontSize * 1.35 }]}>
|
|
45
|
+
{cue.text}
|
|
46
|
+
</Text>
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const styles = StyleSheet.create({
|
|
52
|
+
wrap: {
|
|
53
|
+
position: "absolute",
|
|
54
|
+
left: 0,
|
|
55
|
+
right: 0,
|
|
56
|
+
bottom: 56,
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
paddingHorizontal: 24,
|
|
59
|
+
zIndex: 1,
|
|
60
|
+
},
|
|
61
|
+
videoArea: {
|
|
62
|
+
position: "absolute",
|
|
63
|
+
left: 0,
|
|
64
|
+
top: 0,
|
|
65
|
+
justifyContent: "flex-end",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
paddingHorizontal: 24,
|
|
68
|
+
paddingBottom: 56,
|
|
69
|
+
zIndex: 1,
|
|
70
|
+
},
|
|
71
|
+
text: {
|
|
72
|
+
backgroundColor: "rgba(0,0,0,0.65)",
|
|
73
|
+
color: "#fff",
|
|
74
|
+
textAlign: "center",
|
|
75
|
+
paddingHorizontal: 8,
|
|
76
|
+
paddingVertical: 3,
|
|
77
|
+
borderRadius: 4,
|
|
78
|
+
overflow: "hidden",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MegaVideoPlayerAndroid,
|
|
3
|
+
type MegaVideoPlayerAndroidProps,
|
|
4
|
+
type MegaVideoPlayerAndroidHandle,
|
|
5
|
+
} from "./MegaVideoPlayerAndroid.js";
|
|
6
|
+
export { PlayerControlsAndroid, type PlayerControlsAndroidProps } from "./PlayerControlsAndroid.js";
|
|
7
|
+
export { SubtitleDisplayAndroid, type SubCueShapeAndroid } from "./SubtitleDisplayAndroid.js";
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// The exact MyMovies episode player (components/MegaVideoPlayer.web.tsx),
|
|
2
|
+
// packaged as a cross-platform component (Android / iOS / web). A direct
|
|
3
|
+
// VideoPlayer instance (createVideoPlayer) driven from a class component, with
|
|
4
|
+
// ordered part URLs played back-to-back and auto-advancing to the next part when
|
|
5
|
+
// the current one ends. Sources come in as proxy URLs — from
|
|
6
|
+
// `mega-stream-proxy/native` (http://127.0.0.1:<port>/…) on Android/iOS or from
|
|
7
|
+
// the Service Worker (`/__mmstream__/…`) on the web.
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
|
11
|
+
import { createVideoPlayer, VideoView, type VideoPlayer } from "expo-video";
|
|
12
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
13
|
+
|
|
14
|
+
import { PlayerControlsWeb } from "./PlayerControls.js";
|
|
15
|
+
import {
|
|
16
|
+
loadSubtitles,
|
|
17
|
+
type SubCue,
|
|
18
|
+
type SubtitleFormat,
|
|
19
|
+
type SubtitleTrack,
|
|
20
|
+
} from "../../subtitles/index.js";
|
|
21
|
+
|
|
22
|
+
/** expo-video listener subscriptions just need `remove()`. */
|
|
23
|
+
type EventSubscriptionLike = { remove(): void };
|
|
24
|
+
|
|
25
|
+
export type MegaVideoPlayerWebProps = {
|
|
26
|
+
/** A playable URL — a direct stream or the first part URL. */
|
|
27
|
+
uri?: string | null;
|
|
28
|
+
/** Ordered additional part URLs for multi-part episodes, played in sequence. */
|
|
29
|
+
sources?: string[];
|
|
30
|
+
title?: string;
|
|
31
|
+
/** Pass your subtitle file here — MEGA links, plain http(s) CORS hosts, or a known extension (.srt/.ass/.vtt). */
|
|
32
|
+
subtitleUrl?: string;
|
|
33
|
+
/** optional format hint (defaults to URL extension, then content sniffing) */
|
|
34
|
+
subtitleFormat?: SubtitleFormat;
|
|
35
|
+
/** language name shown in the player controls for the single `subtitleUrl` track
|
|
36
|
+
* (e.g. "English", "French"). Defaults to "SRT". */
|
|
37
|
+
subtitleLang?: string;
|
|
38
|
+
subtitleTracks?: SubtitleTrack[];
|
|
39
|
+
/** preselect a subtitle track by language on load */
|
|
40
|
+
initialSubtitleLang?: string | null;
|
|
41
|
+
/** shift the cue timestamps by an offset in seconds (can be negative) */
|
|
42
|
+
subtitleOffset?: number;
|
|
43
|
+
/** resolution labels in display order — the FIRST is the default source
|
|
44
|
+
* (the episode URL), e.g. ["Default", "1080p", "720p", "480p"]. */
|
|
45
|
+
resolutions?: string[];
|
|
46
|
+
/** currently active resolution label */
|
|
47
|
+
resolution?: string;
|
|
48
|
+
/** select a resolution — the parent resolves the label to its URL and
|
|
49
|
+
* updates `uri` so the new source is loaded. */
|
|
50
|
+
onSelectResolution?: (label: string) => void;
|
|
51
|
+
autoPlay?: boolean;
|
|
52
|
+
onEnd?: () => void;
|
|
53
|
+
onError?: (message: string) => void;
|
|
54
|
+
onProgress?: (info: { currentTime: number; duration: number }) => void;
|
|
55
|
+
onFullscreenChange?: (fullscreen: boolean) => void;
|
|
56
|
+
width?: number;
|
|
57
|
+
height?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type MegaVideoPlayerWebState = {
|
|
61
|
+
playing: boolean;
|
|
62
|
+
buffering: boolean;
|
|
63
|
+
loading: boolean;
|
|
64
|
+
currentTime: number;
|
|
65
|
+
duration: number;
|
|
66
|
+
muted: boolean;
|
|
67
|
+
volume: number;
|
|
68
|
+
visible: boolean;
|
|
69
|
+
subtitlesOn: boolean;
|
|
70
|
+
subFontSize: number;
|
|
71
|
+
cues: SubCue[];
|
|
72
|
+
subtitleError: string | null;
|
|
73
|
+
error: string | null;
|
|
74
|
+
fullscreen: boolean;
|
|
75
|
+
width?: number;
|
|
76
|
+
height?: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export class MegaVideoPlayerWeb extends React.Component<
|
|
80
|
+
MegaVideoPlayerWebProps,
|
|
81
|
+
MegaVideoPlayerWebState
|
|
82
|
+
> {
|
|
83
|
+
private player: VideoPlayer;
|
|
84
|
+
private subs: EventSubscriptionLike[] = [];
|
|
85
|
+
private hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
private mountedRef = true;
|
|
87
|
+
private autoplayRef = true;
|
|
88
|
+
private subTrackIdx = 0;
|
|
89
|
+
private tracks: SubtitleTrack[] = [];
|
|
90
|
+
private sourceIndexRef = 0;
|
|
91
|
+
|
|
92
|
+
constructor(props: MegaVideoPlayerWebProps) {
|
|
93
|
+
super(props);
|
|
94
|
+
this.state = {
|
|
95
|
+
playing: false,
|
|
96
|
+
buffering: true,
|
|
97
|
+
loading: false,
|
|
98
|
+
currentTime: 0,
|
|
99
|
+
duration: 0,
|
|
100
|
+
muted: false,
|
|
101
|
+
volume: 1,
|
|
102
|
+
visible: true,
|
|
103
|
+
subtitlesOn: true,
|
|
104
|
+
subFontSize: 14,
|
|
105
|
+
cues: [],
|
|
106
|
+
subtitleError: null,
|
|
107
|
+
error: null,
|
|
108
|
+
fullscreen: false,
|
|
109
|
+
width: this.props.width,
|
|
110
|
+
height: this.props.height,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
this.player = createVideoPlayer(null);
|
|
114
|
+
this.player.loop = false;
|
|
115
|
+
this.player.timeUpdateEventInterval = 1;
|
|
116
|
+
this.player.bufferOptions = { preferredForwardBufferDuration: 60 };
|
|
117
|
+
this.sourceIndexRef = Math.max(
|
|
118
|
+
0,
|
|
119
|
+
(this.props.sources ?? []).indexOf(this.props.uri ?? ""),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- lifecycle ----
|
|
124
|
+
|
|
125
|
+
componentDidMount() {
|
|
126
|
+
this.mountedRef = true;
|
|
127
|
+
this.autoplayRef = this.props.autoPlay !== false;
|
|
128
|
+
|
|
129
|
+
this.subs.push(
|
|
130
|
+
this.player.addListener("statusChange", ({ status, error }) => {
|
|
131
|
+
if (!this.mountedRef) return;
|
|
132
|
+
console.log(
|
|
133
|
+
`[MegaVideoPlayer] status: ${status}` +
|
|
134
|
+
(error ? ` error: ${error.message}` : ""),
|
|
135
|
+
);
|
|
136
|
+
this.setState({
|
|
137
|
+
buffering: status === "loading",
|
|
138
|
+
error:
|
|
139
|
+
status === "error"
|
|
140
|
+
? (error?.message ?? "Video error")
|
|
141
|
+
: this.state.error,
|
|
142
|
+
});
|
|
143
|
+
if (error) {
|
|
144
|
+
this.props.onError?.(error.message ?? "Video error");
|
|
145
|
+
}
|
|
146
|
+
if (status === "readyToPlay") {
|
|
147
|
+
this.setState({
|
|
148
|
+
buffering: false,
|
|
149
|
+
duration: this.player.duration || this.state.duration,
|
|
150
|
+
});
|
|
151
|
+
if (this.autoplayRef) this.player.play();
|
|
152
|
+
}
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
this.subs.push(
|
|
156
|
+
this.player.addListener("playingChange", ({ isPlaying }) => {
|
|
157
|
+
if (!this.mountedRef) return;
|
|
158
|
+
console.log(`[MegaVideoPlayer] playing: ${isPlaying}`);
|
|
159
|
+
this.setState({ playing: isPlaying });
|
|
160
|
+
if (isPlaying) this.scheduleHide();
|
|
161
|
+
else this.setState({ visible: true });
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
this.subs.push(
|
|
165
|
+
this.player.addListener("timeUpdate", ({ currentTime }) => {
|
|
166
|
+
if (!this.mountedRef) return;
|
|
167
|
+
this.setState({ currentTime });
|
|
168
|
+
this.props.onProgress?.({
|
|
169
|
+
currentTime,
|
|
170
|
+
duration: this.player.duration || this.state.duration,
|
|
171
|
+
});
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
this.subs.push(
|
|
175
|
+
this.player.addListener("playToEnd", () => {
|
|
176
|
+
const sources = this.props.sources ?? [];
|
|
177
|
+
const next = sources[this.sourceIndexRef + 1];
|
|
178
|
+
if (next) {
|
|
179
|
+
this.sourceIndexRef += 1;
|
|
180
|
+
console.log(
|
|
181
|
+
`[MegaVideoPlayer] advancing to part ${this.sourceIndexRef + 1}/${sources.length}`,
|
|
182
|
+
);
|
|
183
|
+
void this.add(next, { title: this.props.title });
|
|
184
|
+
} else {
|
|
185
|
+
this.props.onEnd?.();
|
|
186
|
+
}
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
this.syncSubtitleTracks();
|
|
191
|
+
if (this.props.uri) {
|
|
192
|
+
void this.add(this.props.uri, { title: this.props.title });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
componentDidUpdate(prevProps: MegaVideoPlayerWebProps) {
|
|
197
|
+
if (this.props.uri !== prevProps.uri && this.props.uri) {
|
|
198
|
+
this.sourceIndexRef = Math.max(
|
|
199
|
+
0,
|
|
200
|
+
(this.props.sources ?? []).indexOf(this.props.uri),
|
|
201
|
+
);
|
|
202
|
+
void this.add(this.props.uri, { title: this.props.title });
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
this.props.subtitleTracks !== prevProps.subtitleTracks ||
|
|
206
|
+
this.props.subtitleUrl !== prevProps.subtitleUrl
|
|
207
|
+
) {
|
|
208
|
+
this.syncSubtitleTracks();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
componentWillUnmount() {
|
|
213
|
+
this.mountedRef = false;
|
|
214
|
+
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
215
|
+
this.subs.forEach((s) => s.remove());
|
|
216
|
+
this.subs = [];
|
|
217
|
+
if (this.player) {
|
|
218
|
+
try {
|
|
219
|
+
this.player.pause();
|
|
220
|
+
this.player.release();
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- source management ----
|
|
226
|
+
|
|
227
|
+
/** Loads a new playable URL into the player. */
|
|
228
|
+
async add(source: string, opts?: { title?: string }): Promise<void> {
|
|
229
|
+
if (!source) return;
|
|
230
|
+
this.setState({ error: null, loading: true, buffering: true });
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
if (!this.mountedRef) return;
|
|
234
|
+
console.log(`[MegaVideoPlayer] add: ${source}`);
|
|
235
|
+
this.player.replace({
|
|
236
|
+
uri: source,
|
|
237
|
+
metadata: { title: opts?.title ?? this.props.title },
|
|
238
|
+
});
|
|
239
|
+
this.setState({
|
|
240
|
+
loading: false,
|
|
241
|
+
currentTime: 0,
|
|
242
|
+
duration: 0,
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (!this.mountedRef) return;
|
|
246
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
247
|
+
console.warn("[MegaVideoPlayer] add failed:", message);
|
|
248
|
+
this.setState({ error: message, loading: false, buffering: false });
|
|
249
|
+
this.props.onError?.(message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Stops playback and clears the current source. */
|
|
254
|
+
stop(): void {
|
|
255
|
+
try {
|
|
256
|
+
this.player.pause();
|
|
257
|
+
this.player.replace(null);
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- player controllers (all on the class) ----
|
|
262
|
+
|
|
263
|
+
playPause = (): void => {
|
|
264
|
+
if (this.player.playing) this.player.pause();
|
|
265
|
+
else this.player.play();
|
|
266
|
+
this.setState({ playing: this.player.playing, visible: true });
|
|
267
|
+
this.scheduleHide();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
skip = (delta: number): void => {
|
|
271
|
+
const next = Math.min(
|
|
272
|
+
Math.max(this.state.currentTime + delta, 0),
|
|
273
|
+
this.state.duration,
|
|
274
|
+
);
|
|
275
|
+
this.player.currentTime = next;
|
|
276
|
+
this.setState({ currentTime: next, visible: true });
|
|
277
|
+
this.scheduleHide();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
seek = (seconds: number): void => {
|
|
281
|
+
const clamped = Math.min(
|
|
282
|
+
Math.max(seconds, 0),
|
|
283
|
+
this.state.duration || seconds,
|
|
284
|
+
);
|
|
285
|
+
this.player.currentTime = clamped;
|
|
286
|
+
this.setState({ currentTime: clamped, visible: true });
|
|
287
|
+
this.scheduleHide();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
toggleMute = (): void => {
|
|
291
|
+
const next = !this.state.muted;
|
|
292
|
+
this.player.muted = next;
|
|
293
|
+
this.setState({ muted: next, visible: true });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
changeVolume = (v: number): void => {
|
|
297
|
+
this.player.volume = v;
|
|
298
|
+
this.setState({ volume: v });
|
|
299
|
+
if (v > 0 && this.state.muted) {
|
|
300
|
+
this.player.muted = false;
|
|
301
|
+
this.setState({ muted: false });
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
toggleSubtitles = (): void => {
|
|
306
|
+
this.setState((s) => ({ subtitlesOn: !s.subtitlesOn }));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
toggleControls = (): void => {
|
|
310
|
+
this.setState((s) => ({ visible: !s.visible }));
|
|
311
|
+
if (!this.state.visible && this.state.playing) this.scheduleHide();
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
enterFullscreen = (next?: boolean | unknown): void => {
|
|
315
|
+
// PlayerControls calls through onPress, which passes the press event as
|
|
316
|
+
// the first argument — ignore it (it is not the desired fullscreen value).
|
|
317
|
+
const target = typeof next === "boolean" ? next : !this.state.fullscreen;
|
|
318
|
+
this.setState({ fullscreen: target, visible: true });
|
|
319
|
+
this.props.onFullscreenChange?.(target);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
changeSubtitleFontSize = (px: number): void => {
|
|
323
|
+
this.setState({ subFontSize: px });
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
selectSubtitleTrack = (lang: string): void => {
|
|
327
|
+
const idx = this.tracks.findIndex((t) => t.lang === lang);
|
|
328
|
+
if (idx >= 0) {
|
|
329
|
+
this.subTrackIdx = idx;
|
|
330
|
+
this.setState({ subtitlesOn: true });
|
|
331
|
+
this.loadSubtitles();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// ---- helpers ----
|
|
336
|
+
|
|
337
|
+
private scheduleHide() {
|
|
338
|
+
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
339
|
+
this.hideTimer = setTimeout(() => {
|
|
340
|
+
if (this.state.playing) this.setState({ visible: false });
|
|
341
|
+
}, 3000);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private syncSubtitleTracks() {
|
|
345
|
+
const {
|
|
346
|
+
subtitleTracks,
|
|
347
|
+
subtitleUrl,
|
|
348
|
+
subtitleFormat,
|
|
349
|
+
subtitleLang,
|
|
350
|
+
initialSubtitleLang,
|
|
351
|
+
} = this.props;
|
|
352
|
+
this.tracks = subtitleTracks?.length
|
|
353
|
+
? subtitleTracks
|
|
354
|
+
: subtitleUrl
|
|
355
|
+
? [
|
|
356
|
+
{
|
|
357
|
+
lang: subtitleLang ?? "SRT",
|
|
358
|
+
url: subtitleUrl,
|
|
359
|
+
format: subtitleFormat,
|
|
360
|
+
},
|
|
361
|
+
]
|
|
362
|
+
: [];
|
|
363
|
+
// Preselect the requested track (from the watch screen's subtitle picker)
|
|
364
|
+
if (initialSubtitleLang) {
|
|
365
|
+
const idx = this.tracks.findIndex((t) => t.lang === initialSubtitleLang);
|
|
366
|
+
if (idx >= 0) this.subTrackIdx = idx;
|
|
367
|
+
}
|
|
368
|
+
this.loadSubtitles();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private loadSubtitles() {
|
|
372
|
+
const track =
|
|
373
|
+
this.tracks[Math.min(this.subTrackIdx, this.tracks.length - 1)] ?? null;
|
|
374
|
+
this.setState({ cues: [], subtitleError: null });
|
|
375
|
+
if (!track) return;
|
|
376
|
+
loadSubtitles(track.url, { format: track.format ?? undefined })
|
|
377
|
+
.then((c) => {
|
|
378
|
+
if (!this.mountedRef) return;
|
|
379
|
+
this.setState({
|
|
380
|
+
cues: c,
|
|
381
|
+
subtitleError:
|
|
382
|
+
c.length === 0 ? "No cues parsed from this subtitle file" : null,
|
|
383
|
+
});
|
|
384
|
+
})
|
|
385
|
+
.catch((e: unknown) => {
|
|
386
|
+
console.warn("[subtitles] load failed:", track.url, e);
|
|
387
|
+
if (this.mountedRef)
|
|
388
|
+
this.setState({ subtitleError: "Subtitles unavailable" });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- render ----
|
|
393
|
+
|
|
394
|
+
render() {
|
|
395
|
+
const s = this.state;
|
|
396
|
+
const offset = this.props.subtitleOffset ?? 0;
|
|
397
|
+
const cueTime = s.currentTime - offset;
|
|
398
|
+
const activeCue =
|
|
399
|
+
s.cues.find((c) => cueTime >= c.start && cueTime < c.end) ?? null;
|
|
400
|
+
const activeTrack =
|
|
401
|
+
this.tracks[Math.min(this.subTrackIdx, this.tracks.length - 1)] ?? null;
|
|
402
|
+
|
|
403
|
+
if (s.error) {
|
|
404
|
+
return (
|
|
405
|
+
<View style={[styles.wrap, styles.center]}>
|
|
406
|
+
<Ionicons name="alert-circle-outline" size={40} color="#ff6b6b" />
|
|
407
|
+
<Text style={styles.errorText}>{s.error}</Text>
|
|
408
|
+
<Text style={styles.errorHint}>Use "Download & Play" instead.</Text>
|
|
409
|
+
</View>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<View
|
|
415
|
+
style={[
|
|
416
|
+
styles.wrap,
|
|
417
|
+
{ width: this.state.width, height: this.state.height },
|
|
418
|
+
s.fullscreen && styles.fullscreen,
|
|
419
|
+
]}
|
|
420
|
+
>
|
|
421
|
+
<VideoView
|
|
422
|
+
player={this.player}
|
|
423
|
+
style={[
|
|
424
|
+
StyleSheet.absoluteFill,
|
|
425
|
+
{
|
|
426
|
+
alignSelf: "center",
|
|
427
|
+
width: this.state.width,
|
|
428
|
+
height: this.state.height,
|
|
429
|
+
zIndex: 0
|
|
430
|
+
},
|
|
431
|
+
]}
|
|
432
|
+
nativeControls={false}
|
|
433
|
+
contentFit="contain"
|
|
434
|
+
allowsFullscreen={false}
|
|
435
|
+
/>
|
|
436
|
+
|
|
437
|
+
{s.loading && (
|
|
438
|
+
<View
|
|
439
|
+
style={[styles.centerBadge, { width: this.state.width }]}
|
|
440
|
+
pointerEvents="none"
|
|
441
|
+
>
|
|
442
|
+
<ActivityIndicator color="#e50914" size="large" />
|
|
443
|
+
<Text style={styles.loadingText}>Starting stream…</Text>
|
|
444
|
+
</View>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{s.subtitlesOn && s.subtitleError && !s.cues.length && (
|
|
448
|
+
<View pointerEvents="none" style={styles.subErrorWrap}>
|
|
449
|
+
<Text style={styles.subErrorText}>
|
|
450
|
+
{s.subtitleError} · {activeTrack?.lang ?? "no track"}
|
|
451
|
+
</Text>
|
|
452
|
+
</View>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
<PlayerControlsWeb
|
|
456
|
+
playing={s.playing}
|
|
457
|
+
buffering={s.buffering}
|
|
458
|
+
currentTime={s.currentTime}
|
|
459
|
+
duration={s.duration}
|
|
460
|
+
muted={s.muted}
|
|
461
|
+
title={this.props.title}
|
|
462
|
+
visible={s.visible}
|
|
463
|
+
subtitlesOn={s.subtitlesOn}
|
|
464
|
+
volume={s.volume}
|
|
465
|
+
subtitleTracks={this.tracks.map((t) => t.lang)}
|
|
466
|
+
subtitleTrack={activeTrack?.lang}
|
|
467
|
+
onSelectSubtitleTrack={this.selectSubtitleTrack}
|
|
468
|
+
subtitleFontSize={s.subFontSize}
|
|
469
|
+
onChangeSubtitleFontSize={this.changeSubtitleFontSize}
|
|
470
|
+
resolutions={this.props.resolutions}
|
|
471
|
+
resolution={this.props.resolution}
|
|
472
|
+
onSelectResolution={this.props.onSelectResolution}
|
|
473
|
+
onToggleVisible={this.toggleControls}
|
|
474
|
+
onPlayPause={this.playPause}
|
|
475
|
+
onSkip={this.skip}
|
|
476
|
+
onSeek={this.seek}
|
|
477
|
+
onToggleMute={this.toggleMute}
|
|
478
|
+
onToggleSubtitles={this.toggleSubtitles}
|
|
479
|
+
onVolume={this.changeVolume}
|
|
480
|
+
onFullscreen={this.enterFullscreen}
|
|
481
|
+
width={this.state.width}
|
|
482
|
+
height={this.state.height}
|
|
483
|
+
cue={activeCue}
|
|
484
|
+
enabled={s.subtitlesOn}
|
|
485
|
+
fontSize={s.subFontSize}
|
|
486
|
+
/>
|
|
487
|
+
</View>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const styles = StyleSheet.create({
|
|
493
|
+
wrap: {
|
|
494
|
+
flex: 1,
|
|
495
|
+
width: "100%",
|
|
496
|
+
height: "100%",
|
|
497
|
+
backgroundColor: "#000",
|
|
498
|
+
},
|
|
499
|
+
fullscreen: {
|
|
500
|
+
position: "absolute",
|
|
501
|
+
left: 0,
|
|
502
|
+
right: 0,
|
|
503
|
+
top: 0,
|
|
504
|
+
bottom: 0,
|
|
505
|
+
zIndex: 10,
|
|
506
|
+
},
|
|
507
|
+
center: {
|
|
508
|
+
flex: 1,
|
|
509
|
+
justifyContent: "center",
|
|
510
|
+
alignItems: "center",
|
|
511
|
+
gap: 12,
|
|
512
|
+
padding: 20,
|
|
513
|
+
backgroundColor: "#000",
|
|
514
|
+
},
|
|
515
|
+
errorText: {
|
|
516
|
+
color: "#ff6b6b",
|
|
517
|
+
fontSize: 14,
|
|
518
|
+
textAlign: "center",
|
|
519
|
+
},
|
|
520
|
+
errorHint: {
|
|
521
|
+
color: "#9aa0aa",
|
|
522
|
+
fontSize: 12,
|
|
523
|
+
textAlign: "center",
|
|
524
|
+
},
|
|
525
|
+
centerBadge: {
|
|
526
|
+
...StyleSheet.absoluteFillObject,
|
|
527
|
+
justifyContent: "center",
|
|
528
|
+
alignItems: "center",
|
|
529
|
+
gap: 12,
|
|
530
|
+
},
|
|
531
|
+
loadingText: { color: "#fff", fontSize: 14 },
|
|
532
|
+
subErrorWrap: {
|
|
533
|
+
position: "absolute",
|
|
534
|
+
left: 0,
|
|
535
|
+
right: 0,
|
|
536
|
+
bottom: 56,
|
|
537
|
+
alignItems: "center",
|
|
538
|
+
paddingHorizontal: 24,
|
|
539
|
+
zIndex: 5,
|
|
540
|
+
},
|
|
541
|
+
subErrorText: {
|
|
542
|
+
backgroundColor: "rgba(229,9,20,0.8)",
|
|
543
|
+
color: "#fff",
|
|
544
|
+
fontSize: 12,
|
|
545
|
+
textAlign: "center",
|
|
546
|
+
paddingHorizontal: 10,
|
|
547
|
+
paddingVertical: 4,
|
|
548
|
+
borderRadius: 4,
|
|
549
|
+
overflow: "hidden",
|
|
550
|
+
},
|
|
551
|
+
});
|