pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

Fix casting (#49)

* Tbh i tried adding it and it works but i cant fix the dual casting

* clean up chromecasting

* Apply Chromecast fixes

* Update Icon.tsx

---------

Co-authored-by: Pas <74743263+Pasithea0@users.noreply.github.com>

authored by

Chris
Pas
and committed by
GitHub
47653c29 c45004dc

+105 -135
+2
src/assets/locales/en.json
··· 594 594 "short": "Back" 595 595 }, 596 596 "casting": { 597 + "to": "Casting to {{device}} 📺", 598 + "device": "device", 597 599 "enabled": "Casting to device 🎬" 598 600 }, 599 601 "menus": {
+2 -18
src/components/Icon.tsx
··· 1 1 import classNames from "classnames"; 2 - import { memo, useEffect, useRef } from "react"; 2 + import { memo } from "react"; 3 3 4 4 export enum Icons { 5 5 SEARCH = "search", ··· 128 128 captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></svg>`, 129 129 link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`, 130 130 circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`, 131 - casting: "", 131 + casting: "", // leave blank because Chrome imports it's own icon 132 132 download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`, 133 133 gear: `<svg fill="currentColor" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" viewBox="0 0 48.4 48.4" xml:space="preserve"><g><path d="M48.4,24.2c0-1.8-1.297-3.719-2.896-4.285s-3.149-1.952-3.6-3.045c-0.451-1.093-0.334-3.173,0.396-4.705 c0.729-1.532,0.287-3.807-0.986-5.08c-1.272-1.273-3.547-1.714-5.08-0.985c-1.532,0.729-3.609,0.848-4.699,0.397 s-2.477-2.003-3.045-3.602C27.921,1.296,26,0,24.2,0c-1.8,0-3.721,1.296-4.29,2.895c-0.569,1.599-1.955,3.151-3.045,3.602 c-1.09,0.451-3.168,0.332-4.7-0.397c-1.532-0.729-3.807-0.288-5.08,0.985c-1.273,1.273-1.714,3.547-0.985,5.08 c0.729,1.533,0.845,3.611,0.392,4.703c-0.453,1.092-1.998,2.481-3.597,3.047S0,22.4,0,24.2s1.296,3.721,2.895,4.29 c1.599,0.568,3.146,1.957,3.599,3.047c0.453,1.089,0.335,3.166-0.394,4.698s-0.288,3.807,0.985,5.08 c1.273,1.272,3.547,1.714,5.08,0.985c1.533-0.729,3.61-0.847,4.7-0.395c1.091,0.452,2.476,2.008,3.045,3.604 c0.569,1.596,2.49,2.891,4.29,2.891c1.8,0,3.721-1.295,4.29-2.891c0.568-1.596,1.953-3.15,3.043-3.604 c1.09-0.453,3.17-0.334,4.701,0.396c1.533,0.729,3.808,0.287,5.08-0.985c1.273-1.273,1.715-3.548,0.986-5.08 c-0.729-1.533-0.849-3.61-0.398-4.7c0.451-1.09,2.004-2.477,3.603-3.045C47.104,27.921,48.4,26,48.4,24.2z M24.2,33.08 c-4.91,0-8.88-3.97-8.88-8.87c0-4.91,3.97-8.88,8.88-8.88c4.899,0,8.87,3.97,8.87,8.88C33.07,29.11,29.1,33.08,24.2,33.08z"/></g></svg>`, 134 134 watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`, ··· 183 183 repeat: `<svg viewBox="0 0 24 24" width="1em" height="1em" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>`, 184 184 }; 185 185 186 - function ChromeCastButton() { 187 - const ref = useRef<HTMLDivElement>(null); 188 - 189 - useEffect(() => { 190 - const tag = document.createElement("google-cast-launcher"); 191 - tag.setAttribute("id", "castbutton"); 192 - ref.current?.appendChild(tag); 193 - }, []); 194 - 195 - return <div ref={ref} className="h-6" />; 196 - } 197 - 198 186 export const Icon = memo((props: IconProps) => { 199 - if (props.icon === Icons.CASTING) { 200 - return <ChromeCastButton />; 201 - } 202 - 203 187 const flipClass = 204 188 props.icon === Icons.ARROW_LEFT || 205 189 props.icon === Icons.ARROW_RIGHT ||
+1
src/components/player/Player.tsx
··· 11 11 export * from "./internals/BookmarkButton"; 12 12 export * from "./internals/InfoButton"; 13 13 export * from "./internals/SkipEpisodeButton"; 14 + export * from "./atoms/Chromecast";
+9 -1
src/components/player/atoms/CastingNotification.tsx
··· 8 8 const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); 9 9 const display = usePlayerStore((s) => s.display); 10 10 const isCasting = display?.getType() === "casting"; 11 + const remotePlayer = usePlayerStore((s) => s.casting.player); 11 12 12 13 if (isLoading || !isCasting) return null; 13 14 15 + let deviceName = remotePlayer?.displayName || t("player.casting.device"); 16 + if (deviceName === "Default Media Receiver") { 17 + deviceName = t("player.casting.device"); // e.g., "your TV" 18 + } 19 + 14 20 return ( 15 21 <div className="flex flex-col items-center justify-center gap-4"> 16 22 <div className="rounded-full bg-opacity-10 bg-video-buttonBackground p-3 brightness-100 grayscale"> 17 23 <Icon icon={Icons.CASTING} /> 18 24 </div> 19 - <p className="text-center">{t("player.casting.enabled")}</p> 25 + <p className="text-center"> 26 + {t("player.casting.to", { device: deviceName })} 27 + </p> 20 28 </div> 21 29 ); 22 30 }
+27 -60
src/components/player/atoms/Chromecast.tsx
··· 1 - import { useCallback, useEffect, useRef, useState } from "react"; 1 + /// <reference types="chromecast-caf-sender" /> 2 + 3 + import { useEffect, useRef, useState } from "react"; 2 4 3 - import { Icons } from "@/components/Icon"; 4 5 import { VideoPlayerButton } from "@/components/player/internals/Button"; 5 6 import { usePlayerStore } from "@/stores/player/store"; 6 7 7 - // Allow the custom element in TSX without adding a global d.ts file 8 + // Allow the custom element in TSX 8 9 /* eslint-disable @typescript-eslint/no-namespace */ 9 10 declare global { 10 11 namespace JSX { ··· 19 20 className?: string; 20 21 } 21 22 22 - export function Chromecast(props: ChromecastProps) { 23 - const [hidden, setHidden] = useState(false); 23 + export function Chromecast({ className }: ChromecastProps) { 24 24 const [castHidden, setCastHidden] = useState(false); 25 25 const isCasting = usePlayerStore((s) => s.interface.isCasting); 26 - const ref = useRef<HTMLButtonElement>(null); 27 - 28 - const setButtonVisibility = useCallback( 29 - (tag: HTMLElement) => { 30 - const isVisible = (tag.getAttribute("style") ?? "").includes("inline"); 31 - setHidden(!isVisible); 32 - }, 33 - [setHidden], 34 - ); 26 + const launcherRef = useRef<HTMLDivElement>(null); 35 27 36 28 useEffect(() => { 37 - const tag = ref.current?.querySelector<HTMLElement>("google-cast-launcher"); 38 - if (!tag) return; 39 - 40 - const observer = new MutationObserver(() => { 41 - setButtonVisibility(tag); 42 - }); 43 - 44 - observer.observe(tag, { attributes: true, attributeFilter: ["style"] }); 45 - setButtonVisibility(tag); 46 - 47 - return () => { 48 - observer.disconnect(); 49 - }; 50 - }, [setButtonVisibility]); 29 + const w = window as unknown as { cast?: typeof cast }; 30 + const castFramework = w.cast?.framework; 31 + if (!castFramework) return; 51 32 52 - // Hide the button when there are no cast devices available according to CAF 53 - useEffect(() => { 54 - const w = window as any; 55 - const cast = w?.cast; 56 - if (!cast?.framework) return; 57 - 58 - const context = cast.framework.CastContext.getInstance(); 59 - const update = () => { 33 + const context = castFramework.CastContext.getInstance(); 34 + const updateVisibility = () => { 60 35 const state = context.getCastState(); 61 - setCastHidden(state === cast.framework.CastState.NO_DEVICES_AVAILABLE); 36 + setCastHidden(state === castFramework.CastState.NO_DEVICES_AVAILABLE); 62 37 }; 63 - const handler = () => update(); 64 38 39 + const handler = () => updateVisibility(); 65 40 context.addEventListener( 66 - cast.framework.CastContextEventType.CAST_STATE_CHANGED, 41 + castFramework.CastContextEventType.CAST_STATE_CHANGED, 67 42 handler, 68 43 ); 69 - update(); 44 + updateVisibility(); 70 45 71 46 return () => { 72 47 context.removeEventListener( 73 - cast.framework.CastContextEventType.CAST_STATE_CHANGED, 48 + castFramework.CastContextEventType.CAST_STATE_CHANGED, 74 49 handler, 75 50 ); 76 51 }; 77 52 }, []); 78 53 54 + useEffect(() => { 55 + if (!launcherRef.current || launcherRef.current.children.length > 0) return; 56 + 57 + const launcher = document.createElement("google-cast-launcher"); 58 + launcherRef.current.appendChild(launcher); 59 + }, []); 60 + 79 61 return ( 80 62 <VideoPlayerButton 81 - ref={ref} 82 63 className={[ 83 - props.className ?? "", 64 + className ?? "", 84 65 "google-cast-button", 66 + "cast-button-container", 85 67 isCasting ? "casting" : "", 86 - hidden || castHidden ? "hidden" : "", 68 + castHidden ? "hidden" : "", 87 69 ].join(" ")} 88 - icon={Icons.CASTING} 89 - onClick={(el) => { 90 - const castButton = el.querySelector("google-cast-launcher"); 91 - if (castButton) (castButton as HTMLDivElement).click(); 92 - }} 93 70 > 94 - {/* Render a hidden launcher so programmatic click always works */} 95 - <google-cast-launcher 96 - style={{ 97 - width: 0, 98 - height: 0, 99 - opacity: 0, 100 - position: "absolute", 101 - pointerEvents: "none", 102 - }} 103 - aria-hidden="true" 104 - /> 71 + <div ref={launcherRef} /> 105 72 </VideoPlayerButton> 106 73 ); 107 74 }
+25 -27
src/components/player/internals/CastingInternal.tsx
··· 90 90 ); 91 91 92 92 useEffect(() => { 93 - if (!available || !window.cast || !window.chrome || !window.chrome.cast) 94 - return; 95 - 96 - if (!chrome.cast || !chrome.cast.media) { 97 - console.error( 98 - "Chrome Cast API not fully initialized: chrome.cast.media is undefined", 99 - ); 93 + if ( 94 + !available || 95 + !window.cast?.framework || 96 + !window.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID || 97 + !window.chrome.cast.AutoJoinPolicy 98 + ) { 100 99 return; 101 100 } 102 101 102 + let newPlayer: cast.framework.RemotePlayer | null = null; 103 + let newController: cast.framework.RemotePlayerController | null = null; 104 + 103 105 try { 104 106 const ins = cast.framework.CastContext.getInstance(); 105 107 setInstance(ins); ··· 110 112 ins.setOptions({ 111 113 receiverApplicationId: receiverAppId, 112 114 autoJoinPolicy, 115 + androidReceiverCompatible: false, 116 + resumeSavedSession: false, 113 117 }); 114 118 115 - const newPlayer = new cast.framework.RemotePlayer(); 119 + newPlayer = new cast.framework.RemotePlayer(); 120 + newController = new cast.framework.RemotePlayerController(newPlayer); 116 121 setPlayer(newPlayer); 117 - const newControlller = new cast.framework.RemotePlayerController( 118 - newPlayer, 119 - ); 120 - setController(newControlller); 122 + setController(newController); 121 123 122 - newControlller.addEventListener( 124 + newController.addEventListener( 123 125 cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, 124 126 connectionChanged, 125 127 ); 128 + } catch (error) { 129 + console.error("Error initializing Chromecast:", error); 130 + return; 131 + } 126 132 127 - return () => { 128 - newControlller.removeEventListener( 133 + return () => { 134 + if (newController) { 135 + newController.removeEventListener( 129 136 cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, 130 137 connectionChanged, 131 138 ); 132 - }; 133 - } catch (error) { 134 - console.error("Error initializing Chromecast:", error); 135 - } 136 - }, [ 137 - available, 138 - setPlayer, 139 - setController, 140 - setInstance, 141 - setIsCasting, 142 - connectionChanged, 143 - ]); 139 + } 140 + }; 141 + }, [available, setPlayer, setController, setInstance, connectionChanged]); 144 142 145 143 return null; 146 144 }
+12 -2
src/hooks/useChromecastAvailable.ts
··· 1 - /// <reference types="chromecast-caf-sender"/> 1 + /// <reference types="chromecast-caf-sender" /> 2 2 3 3 import { useEffect, useState } from "react"; 4 4 ··· 8 8 const [available, setAvailable] = useState<boolean | null>(null); 9 9 10 10 useEffect(() => { 11 - isChromecastAvailable((bool) => setAvailable(bool)); 11 + let isMounted = true; 12 + 13 + isChromecastAvailable((bool) => { 14 + if (isMounted) { 15 + setAvailable(bool); 16 + } 17 + }); 18 + 19 + return () => { 20 + isMounted = false; 21 + }; 12 22 }, []); 13 23 14 24 return available;
+5 -9
src/pages/parts/player/PlayerPart.tsx
··· 85 85 <Player.SubtitleView controlsShown={showTargets} /> 86 86 87 87 {status === playerStatus.PLAYING ? ( 88 - <> 89 - <Player.CenterControls> 90 - <Player.LoadingSpinner /> 91 - <Player.AutoPlayStart /> 92 - </Player.CenterControls> 93 - <Player.CenterControls> 94 - <Player.CastingNotification /> 95 - </Player.CenterControls> 96 - </> 88 + <Player.CenterControls> 89 + <Player.LoadingSpinner /> 90 + <Player.AutoPlayStart /> 91 + <Player.CastingNotification /> 92 + </Player.CenterControls> 97 93 ) : null} 98 94 99 95 <Player.CenterMobileControls
+22 -18
src/setup/chromecast.ts
··· 1 + /// <reference types="chromecast-caf-sender" /> 2 + 1 3 const CHROMECAST_SENDER_SDK = 2 4 "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; 3 5 ··· 8 10 function init(available: boolean) { 9 11 _available = available; 10 12 callbacks.forEach((cb) => cb(available)); 11 - // Clear callbacks after first resolution to avoid leaks/repeated calls 12 13 callbacks.length = 0; 13 14 } 14 15 15 16 export function isChromecastAvailable(cb: (available: boolean) => void) { 16 - if (_available !== null) return cb(_available); 17 + if (_available !== null) { 18 + setTimeout(() => cb(_available!), 0); 19 + return; 20 + } 17 21 callbacks.push(cb); 18 22 } 19 23 ··· 21 25 if (_initialized) return; 22 26 _initialized = true; 23 27 24 - const w = window as any; 25 - // Only set the global callback if not already present 26 - if (!w.__onGCastApiAvailable) { 27 - w.__onGCastApiAvailable = (isAvailable: boolean) => { 28 + if (!(window as any).__onGCastApiAvailable) { 29 + (window as any).__onGCastApiAvailable = (isAvailable: boolean) => { 28 30 try { 29 - if (isAvailable && w.cast?.framework) { 30 - const context = w.cast.framework.CastContext.getInstance(); 31 + if (isAvailable && (window as any).cast?.framework) { 32 + const context = ( 33 + window as any 34 + ).cast.framework.CastContext.getInstance(); 31 35 context.setOptions({ 32 - receiverApplicationId: 33 - w.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID, 34 - autoJoinPolicy: w.cast.framework.AutoJoinPolicy.ORIGIN_SCOPED, 36 + receiverApplicationId: (window as any).chrome?.cast?.media 37 + ?.DEFAULT_MEDIA_RECEIVER_APP_ID, 38 + autoJoinPolicy: (window as any).cast.framework.AutoJoinPolicy 39 + .ORIGIN_SCOPED, 35 40 }); 36 41 } 37 - } catch { 38 - // Swallow errors; availability will still be reported below 42 + } catch (e) { 43 + console.warn("Chromecast initialization error:", e); 39 44 } finally { 40 45 init(!!isAvailable); 41 46 } 42 47 }; 43 48 } 44 49 45 - // add script if doesnt exist yet 46 - const exists = !!document.getElementById("chromecast-script"); 47 - if (!exists) { 50 + if (!document.getElementById("chromecast-script")) { 48 51 const script = document.createElement("script"); 49 - script.setAttribute("src", CHROMECAST_SENDER_SDK); 50 - script.setAttribute("id", "chromecast-script"); 52 + script.src = CHROMECAST_SENDER_SDK; 53 + script.id = "chromecast-script"; 54 + script.onerror = () => console.warn("Failed to load Chromecast SDK"); 51 55 document.body.appendChild(script); 52 56 } 53 57 }