pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
at main 364 lines 13 kB view raw
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}