pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
1import { useCallback, useEffect, useState } from "react";
2
3import { prepareStream } from "@/backend/extension/streams";
4import { Button } from "@/components/buttons/Button";
5import { Toggle } from "@/components/buttons/Toggle";
6import { Dropdown } from "@/components/form/Dropdown";
7import { Icon, Icons } from "@/components/Icon";
8import { usePlayer } from "@/components/player/hooks/usePlayer";
9import { convertProviderCaption } from "@/components/player/utils/captions";
10import { Title } from "@/components/text/Title";
11import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
12import { TextInputControl } from "@/components/text-inputs/TextInputControl";
13import { Divider } from "@/components/utils/Divider";
14import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
15import { PlayerPart } from "@/pages/parts/player/PlayerPart";
16import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
17import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities";
18import { type ExtensionStatus, getExtensionState } from "@/utils/extension";
19
20const testMeta: PlayerMeta = {
21 releaseYear: 2010,
22 title: "Sintel",
23 tmdbId: "45745",
24 type: "movie",
25 poster: "https://image.tmdb.org/t/p/w342//4BMG9hk9NvSBeQvC82sVmVRK140.jpg",
26};
27
28const testStreams: Record<StreamType, string> = {
29 hls: "https://alpha-charlott.github.io/video-openh264/Sintel_master.m3u8",
30 mp4: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
31};
32
33const streamTypes: Record<StreamType, string> = {
34 hls: "HLS",
35 mp4: "MP4",
36};
37
38export default function VideoTesterView() {
39 const { status, playMedia, setMeta, reset } = usePlayer();
40 const [selected, setSelected] = useState("mp4");
41 const [inputSource, setInputSource] = useState("");
42 const [extensionState, setExtensionState] =
43 useState<ExtensionStatus>("unknown");
44 const [headersEnabled, setHeadersEnabled] = useState(false);
45 const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>(
46 [{ key: "", value: "" }],
47 );
48
49 // Check extension state on mount
50 useEffect(() => {
51 getExtensionState().then(setExtensionState);
52 }, []);
53
54 // Header management functions
55 const addHeader = useCallback(() => {
56 setHeaders((prev) => [...prev, { key: "", value: "" }]);
57 }, []);
58
59 const updateHeader = useCallback(
60 (index: number, field: "key" | "value", value: string) => {
61 setHeaders((prev) =>
62 prev.map((header, i) =>
63 i === index ? { ...header, [field]: value } : header,
64 ),
65 );
66 },
67 [],
68 );
69
70 const removeHeader = useCallback((index: number) => {
71 setHeaders((prev) => prev.filter((_, i) => i !== index));
72 }, []);
73
74 const toggleHeaders = useCallback(() => {
75 const newEnabled = !headersEnabled;
76 setHeadersEnabled(newEnabled);
77 if (!newEnabled) {
78 setHeaders([{ key: "", value: "" }]);
79 }
80 }, [headersEnabled]);
81
82 const start = useCallback(
83 async (url: string, type: StreamType) => {
84 // Build headers object from enabled headers
85 const headersObj: Record<string, string> = {};
86 if (headersEnabled) {
87 headers.forEach(({ key, value }) => {
88 if (key.trim() && value.trim()) {
89 headersObj[key.trim()] = value.trim();
90 }
91 });
92 }
93
94 let source: SourceSliceSource;
95 if (type === "hls") {
96 source = {
97 type: "hls",
98 url,
99 ...(Object.keys(headersObj).length > 0 && { headers: headersObj }),
100 };
101 } else if (type === "mp4") {
102 source = {
103 type: "file",
104 qualities: {
105 unknown: {
106 type: "mp4",
107 url,
108 },
109 },
110 ...(Object.keys(headersObj).length > 0 && { headers: headersObj }),
111 };
112 } else throw new Error("Invalid type");
113
114 // Prepare stream headers if extension is active and headers are present
115 if (extensionState === "success" && Object.keys(headersObj).length > 0) {
116 // Create a mock Stream object for prepareStream
117 const mockStream: any = {
118 type: type === "hls" ? "hls" : "file",
119 ...(type === "hls"
120 ? { playlist: url }
121 : {
122 qualities: {
123 unknown: {
124 type: "mp4",
125 url,
126 },
127 },
128 }),
129 headers: headersObj,
130 };
131 try {
132 await prepareStream(mockStream);
133 } catch (error) {
134 console.warn("Failed to prepare stream headers:", error);
135 }
136 }
137
138 setMeta(testMeta);
139 playMedia(source, [], null);
140 },
141 [playMedia, setMeta, headersEnabled, headers, extensionState],
142 );
143
144 const startFromCli = useCallback(async () => {
145 try {
146 const clipboardText = await navigator.clipboard.readText();
147
148 // Parse JavaScript object notation by evaluating it safely
149 let cliData;
150 try {
151 // Try to parse as JSON first (in case it's already valid JSON)
152 cliData = JSON.parse(clipboardText);
153 } catch {
154 // If JSON parsing fails, try to evaluate as JavaScript object
155 try {
156 // Use Function constructor to safely evaluate the JavaScript object
157 // eslint-disable-next-line no-new-func
158 cliData = new Function(`return (${clipboardText})`)();
159 } catch {
160 throw new Error(
161 "Invalid JavaScript object format. Please ensure the CLI output is properly formatted.",
162 );
163 }
164 }
165
166 if (
167 !cliData.stream ||
168 !Array.isArray(cliData.stream) ||
169 cliData.stream.length === 0
170 ) {
171 throw new Error("Invalid CLI output: no stream data found");
172 }
173
174 const streamData = cliData.stream[0]; // Take the first stream
175
176 let source: SourceSliceSource;
177 if (streamData.type === "hls") {
178 source = {
179 type: "hls",
180 url: streamData.playlist,
181 ...(streamData.headers && { headers: streamData.headers }),
182 };
183 } else if (streamData.type === "file") {
184 // Handle file type streams
185 const qualities = streamData.qualities || {};
186 const qualityKeys = Object.keys(qualities);
187 if (qualityKeys.length === 0) {
188 throw new Error("Invalid file stream: no qualities found");
189 }
190 source = {
191 type: "file",
192 qualities,
193 ...(streamData.headers && { headers: streamData.headers }),
194 };
195 } else {
196 throw new Error(`Unsupported stream type: ${streamData.type}`);
197 }
198
199 // Convert captions
200 const captions = streamData.captions
201 ? convertProviderCaption(streamData.captions)
202 : [];
203
204 // Prepare stream headers if extension is active and headers are present
205 if (
206 extensionState === "success" &&
207 streamData.headers &&
208 Object.keys(streamData.headers).length > 0
209 ) {
210 try {
211 await prepareStream(streamData);
212 } catch (error) {
213 console.warn("Failed to prepare stream headers:", error);
214 }
215 }
216
217 setMeta(testMeta);
218 playMedia(source, captions, streamData.id);
219 } catch (error) {
220 console.error("Failed to parse CLI data:", error);
221
222 let errorMessage =
223 error instanceof Error ? error.message : "Unknown error";
224
225 // Check for common JSON/JavaScript formatting issues
226 if (
227 errorMessage.includes("Expected property name") ||
228 errorMessage.includes("Unexpected token")
229 ) {
230 errorMessage +=
231 "\n\nThe CLI output should be in JavaScript object format. Make sure you're copying the complete output from your CLI tool.";
232 }
233
234 // eslint-disable-next-line no-alert
235 alert(`Failed to parse CLI data: ${errorMessage}`);
236 }
237 }, [playMedia, setMeta, extensionState]);
238
239 // player meta and streams carry over, so reset on mount
240 useEffect(() => {
241 if (status !== playerStatus.IDLE) {
242 reset();
243 }
244 // eslint-disable-next-line react-hooks/exhaustive-deps
245 }, []);
246
247 return (
248 <PlayerPart backUrl="/dev">
249 {status === playerStatus.IDLE ? (
250 <div className="absolute inset-0 flex items-center justify-center">
251 <div className="w-full max-w-4xl rounded-xl bg-video-scraping-card p-10 m-4">
252 <div className="flex gap-16 flex-col lg:flex-row">
253 <div className="flex-1">
254 <Title>Custom stream</Title>
255 <div className="grid grid-cols-[1fr,auto] gap-2 items-center">
256 <TextInputControl
257 className="bg-video-context-flagBg rounded-md p-2 text-white w-full"
258 value={inputSource}
259 onChange={setInputSource}
260 placeholder="https://..."
261 />
262 <Dropdown
263 options={Object.entries(streamTypes).map((v) => ({
264 id: v[0],
265 name: v[1],
266 }))}
267 selectedItem={{
268 id: selected,
269 name: streamTypes[selected as StreamType],
270 }}
271 setSelectedItem={(item) => setSelected(item.id)}
272 />
273 </div>
274
275 {extensionState === "success" && (
276 <div className="flex-1 mb-4">
277 <div className="flex justify-between items-center gap-4">
278 <div className="my-3">
279 <p className="text-white font-bold">Headers</p>
280 </div>
281 <div>
282 <Toggle
283 onClick={toggleHeaders}
284 enabled={headersEnabled}
285 />
286 </div>
287 </div>
288 {headersEnabled && (
289 <>
290 <Divider marginClass="my-6 px-8 box-content -mx-8" />
291 <div className="my-6 space-y-2">
292 {headers.length === 0 ? (
293 <p>No headers configured.</p>
294 ) : (
295 headers.map((header, index) => (
296 <div
297 // eslint-disable-next-line react/no-array-index-key
298 key={index}
299 className="grid grid-cols-[1fr,1fr,auto] items-center gap-2"
300 >
301 <AuthInputBox
302 value={header.key}
303 onChange={(value) =>
304 updateHeader(index, "key", value)
305 }
306 placeholder="Key"
307 />
308 <AuthInputBox
309 value={header.value}
310 onChange={(value) =>
311 updateHeader(index, "value", value)
312 }
313 placeholder="Value"
314 />
315 <button
316 type="button"
317 onClick={() => removeHeader(index)}
318 className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
319 >
320 <Icon className="text-xl" icon={Icons.X} />
321 </button>
322 </div>
323 ))
324 )}
325 </div>
326
327 <Button theme="purple" onClick={addHeader}>
328 Add header
329 </Button>
330 </>
331 )}
332 </div>
333 )}
334
335 <div className="flex gap-2">
336 <Button
337 onClick={() => start(inputSource, selected as StreamType)}
338 >
339 Start stream
340 </Button>
341 <Button onClick={startFromCli} className="col-span-2">
342 Paste from CLI
343 </Button>
344 </div>
345 </div>
346 <div className="flex-1">
347 <Title>Preset tests</Title>
348 <div className="grid grid-cols-[1fr,1fr] gap-2">
349 <Button onClick={() => start(testStreams.hls, "hls")}>
350 HLS test
351 </Button>
352 <Button onClick={() => start(testStreams.mp4, "mp4")}>
353 MP4 test
354 </Button>
355 </div>
356 </div>
357 </div>
358 </div>
359 </div>
360 ) : null}
361 {status === playerStatus.PLAYBACK_ERROR ? <PlaybackErrorPart /> : null}
362 </PlayerPart>
363 );
364}