Live video on the AT Protocol

app: remove legacy player

-3107
-1
js/app/components/index.tsx
··· 1 1 export { Countdown } from "./countdown"; 2 - export { Player } from "./player/player"; 3 2 export { default as Provider } from "./provider/provider"; 4 3 export { Settings } from "./settings/settings";
-405
js/app/components/livestream/livestream.tsx
··· 1 - import { 2 - LivestreamProvider, 3 - useLivestream, 4 - useProfile, 5 - useSegment, 6 - useViewers, 7 - } from "@streamplace/components"; 8 - import { MessageCircleMore, MessageCircleOff } from "@tamagui/lucide-icons"; 9 - import { useToastController } from "@tamagui/toast"; 10 - import Chat from "components/chat/chat"; 11 - import ChatBox from "components/chat/chat-box"; 12 - import FollowButton from "components/follow-button"; 13 - import Avatar from "components/home/avatar"; 14 - import Loading from "components/loading/loading"; 15 - import { Player } from "components/player/player"; 16 - import { PlayerProps } from "components/player/props"; 17 - import Timer from "components/timer"; 18 - import Viewers from "components/viewers"; 19 - import { useFullscreen } from "contexts/FullscreenContext"; 20 - import { 21 - setSidebarHidden, 22 - setSidebarUnhidden, 23 - } from "features/base/sidebarSlice"; 24 - import { getProfile } from "features/bluesky/blueskySlice"; 25 - import useAvatars from "hooks/useAvatars"; 26 - import { useKeyboard } from "hooks/useKeyboard"; 27 - import usePlatform from "hooks/usePlatform"; 28 - import { useCallback, useEffect, useState } from "react"; 29 - import { 30 - LayoutChangeEvent, 31 - Linking, 32 - View as RNView, 33 - SafeAreaView, 34 - } from "react-native"; 35 - import storage from "storage"; 36 - import { useAppDispatch } from "store/hooks"; 37 - import { 38 - Button, 39 - isWeb, 40 - ScrollView, 41 - Text, 42 - useWindowDimensions, 43 - View, 44 - } from "tamagui"; 45 - 46 - export default function Livestream(props: Partial<PlayerProps>) { 47 - if (props.src === undefined) { 48 - console.error("Livestream: src prop is required"); 49 - return <Text>Source is undefined</Text>; 50 - } 51 - return ( 52 - <LivestreamProvider src={props.src} {...props}> 53 - <LivestreamInner {...props} /> 54 - </LivestreamProvider> 55 - ); 56 - } 57 - 58 - export function LivestreamInner(props: Partial<PlayerProps>) { 59 - const toast = useToastController(); 60 - const viewers = useViewers(); 61 - 62 - const { src, ...extraProps } = props; 63 - const dispatch = useAppDispatch(); 64 - const { width, height } = useWindowDimensions(); 65 - const segment = useSegment(); 66 - const video = segment?.video?.[0]; 67 - const [videoWidth, setVideoWidth] = useState(0); 68 - const [videoHeight, setVideoHeight] = useState(0); 69 - const { keyboardHeight } = useKeyboard(); 70 - const { isIOS } = usePlatform(); 71 - 72 - const [outerHeight, setOuterHeight] = useState(0); 73 - const [innerHeight, setInnerHeight] = useState(0); 74 - const [isChatVisible, setIsChatVisible] = useState(true); 75 - const [offline, setOffline] = useState(true); 76 - const [currentUserDID, setCurrentUserDID] = useState<string | null>(null); 77 - const { fullscreen, setFullscreen } = useFullscreen(); 78 - 79 - useEffect(() => { 80 - if (fullscreen) { 81 - dispatch(setSidebarHidden()); 82 - } else { 83 - dispatch(setSidebarUnhidden()); 84 - } 85 - }, [setFullscreen]); 86 - 87 - const livestream = useLivestream(); 88 - const streamerProfile = useProfile(); 89 - 90 - const streamerDID = livestream?.author?.did; 91 - const streamerHandle = streamerProfile?.handle; 92 - const startTime = livestream?.record?.createdAt 93 - ? new Date(livestream?.record?.createdAt) 94 - : undefined; 95 - 96 - const didArr = livestream?.author?.did ? [livestream?.author?.did] : []; 97 - 98 - const avi = useAvatars(didArr)[didArr[0]]; 99 - 100 - // this would all be really easy if i had library that would give me the 101 - // safe area view height and width but i don't. so let's measure 102 - const onInnerLayout = useCallback((event: LayoutChangeEvent) => { 103 - const { width, height } = event.nativeEvent.layout; 104 - setInnerHeight(height); 105 - }, []); 106 - 107 - const onOuterLayout = useCallback((event: LayoutChangeEvent) => { 108 - const { width, height } = event.nativeEvent.layout; 109 - setOuterHeight(height); 110 - }, []); 111 - 112 - useEffect(() => { 113 - if (video) { 114 - const ratio = video.width / width; 115 - setVideoWidth(video.width / ratio); 116 - setVideoHeight(video.height / ratio); 117 - } 118 - }, [video, width, height]); 119 - 120 - useEffect(() => { 121 - getCurrentUserDID().then((did) => { 122 - console.log("currentUserDID:", did); 123 - setCurrentUserDID(did); 124 - }); 125 - }, []); 126 - 127 - useEffect(() => { 128 - if (streamerDID && !streamerProfile) { 129 - dispatch(getProfile(streamerDID)); 130 - } 131 - }, [streamerDID, streamerProfile, dispatch]); 132 - 133 - useEffect(() => { 134 - // 10 second cut off for segements 135 - const cuttOffDate = new Date(Date.now() - 10 * 1000); 136 - // 15 second cut off if segment start time not found 137 - const startTime = segment?.startTime 138 - ? new Date(segment?.startTime) 139 - : new Date(Date.now() - 15 * 1000); 140 - 141 - if (startTime > cuttOffDate) { 142 - setOffline(false); 143 - } 144 - }, [segment]); 145 - 146 - let slideKeyboard = 0; 147 - if (isIOS && keyboardHeight > 0) { 148 - slideKeyboard = -keyboardHeight + (outerHeight - innerHeight); 149 - } 150 - 151 - const handleFollowChange = (isFollowing: boolean) => { 152 - if (!streamerHandle) return; 153 - if (isFollowing) { 154 - toast.show(`You are now following @${streamerHandle}`); 155 - } else { 156 - toast.show(`You have unfollowed @${streamerHandle}`); 157 - } 158 - }; 159 - 160 - const MainView = width < height && width < 980 ? View : ScrollView; 161 - 162 - const dir = width < height && width < 980 ? "column" : "row"; 163 - 164 - return ( 165 - <RNView style={{ flex: 1 }}> 166 - <SafeAreaView style={{ flex: 1 }} onLayout={onOuterLayout}> 167 - <RNView 168 - style={{ flex: 1, position: "relative" }} 169 - onLayout={onInnerLayout} 170 - > 171 - {videoWidth === 0 && ( 172 - <View 173 - f={1} 174 - position="absolute" 175 - top={0} 176 - left={0} 177 - right={0} 178 - bottom={0} 179 - > 180 - <Loading /> 181 - </View> 182 - )} 183 - <View 184 - f={1} 185 - opacity={videoWidth === 0 ? 0 : 1} 186 - flexDirection={dir} 187 - zIndex={2} 188 - > 189 - <MainView 190 - width={videoWidth} 191 - maxHeight={videoHeight} 192 - maxWidth={videoWidth} 193 - fs={0} 194 - $gtXs={{ fs: 1, maxHeight: "100%" }} 195 - zIndex={2} 196 - > 197 - <View 198 - maxHeight={fullscreen ? height : height * 0.88} 199 - $gtLg={{ maxHeight: fullscreen ? height : height * 0.95 }} 200 - $gtXxl={{ maxHeight: fullscreen ? height : height * 0.9 }} 201 - $platform-ios={{ 202 - height: videoHeight, 203 - }} 204 - $platform-android={{ 205 - height: videoHeight, 206 - }} 207 - > 208 - <Player 209 - src={src} 210 - fullscreen={fullscreen} 211 - setFullscreen={setFullscreen} 212 - {...extraProps} 213 - /> 214 - </View> 215 - {!fullscreen && ( 216 - <View 217 - fg={0} 218 - px="$4" 219 - py="$3" 220 - flexDirection="row" 221 - justifyContent="space-between" 222 - maxWidth="100%" 223 - borderBottomWidth="$0.5" 224 - borderTopWidth="$0.5" 225 - borderColor="$black5" 226 - style={ 227 - dir === "row" 228 - ? { 229 - backgroundColor: "$colorTransparent", 230 - paddingHorizontal: 6, 231 - borderBottomWidth: 0, 232 - borderTopWidth: 0, 233 - } 234 - : { 235 - backgroundColor: "#121212", 236 - } 237 - } 238 - > 239 - <View 240 - flexDirection="row" 241 - alignItems="flex-start" 242 - justifyContent="space-between" 243 - > 244 - <View 245 - flexDirection="row" 246 - alignItems="center" 247 - gap="$3" 248 - minWidth={0} 249 - flexShrink={1} 250 - overflow="hidden" 251 - > 252 - <Avatar src={avi?.avatar} /> 253 - <View 254 - flexDirection="column" 255 - alignItems="flex-start" 256 - gap="$2" 257 - minWidth={0} 258 - flexShrink={1} 259 - maxWidth="100%" 260 - overflow="hidden" 261 - > 262 - <View 263 - flexDirection="row" 264 - alignItems="center" 265 - flexShrink={1} 266 - minWidth={0} 267 - > 268 - {streamerDID && !streamerHandle ? ( 269 - // Skeleton loader for handle 270 - <Text>&nbsp;</Text> 271 - ) : ( 272 - streamerHandle && ( 273 - <Text 274 - onPress={() => 275 - Linking.openURL( 276 - `https://bsky.app/profile/${streamerHandle}`, 277 - ) 278 - } 279 - hoverStyle={{ 280 - color: "$blue11", 281 - }} 282 - aria-label={`View @${streamerHandle} on Bluesky`} 283 - style={isWeb ? { cursor: "pointer" } : {}} 284 - ellipse={true} 285 - > 286 - {`@${streamerHandle}`} 287 - </Text> 288 - ) 289 - )} 290 - {streamerDID && streamerHandle && currentUserDID && ( 291 - <FollowButton 292 - streamerDID={streamerDID} 293 - currentUserDID={currentUserDID} 294 - onFollowChange={handleFollowChange} 295 - /> 296 - )} 297 - </View> 298 - <Text 299 - fontSize="$6" 300 - numberOfLines={1} 301 - ellipse={true} 302 - maxWidth="100%" 303 - minWidth={0} 304 - flexShrink={1} 305 - > 306 - {livestream?.record.title} 307 - </Text> 308 - </View> 309 - </View> 310 - </View> 311 - <View 312 - flexDirection="row" 313 - alignItems="center" 314 - gap="$2" 315 - display="none" 316 - $gtXs={{ display: "flex" }} 317 - > 318 - {startTime instanceof Date && !offline && ( 319 - <Timer start={startTime} /> 320 - )} 321 - <Viewers viewers={viewers ?? 0} /> 322 - <Button 323 - backgroundColor="transparent" 324 - onPress={() => setIsChatVisible(!isChatVisible)} 325 - marginLeft="$2" 326 - style={{ display: dir === "row" ? "hidden" : "flex" }} 327 - > 328 - {isChatVisible ? ( 329 - <MessageCircleOff size={22} /> 330 - ) : ( 331 - <MessageCircleMore size={22} /> 332 - )} 333 - </Button> 334 - </View> 335 - </View> 336 - )} 337 - </MainView> 338 - 339 - {!fullscreen && ( 340 - <View 341 - fg={1} 342 - fs={1} 343 - zIndex={1} 344 - backgroundColor="$background2" 345 - animation={"quick"} 346 - pt="$11" 347 - $gtXs={{ pt: 0 }} 348 - transform={ 349 - isIOS 350 - ? [ 351 - { 352 - translateY: slideKeyboard, 353 - }, 354 - ] 355 - : undefined 356 - } 357 - style={ 358 - dir === "row" 359 - ? { 360 - paddingTop: 0, 361 - width: isChatVisible ? 380 : 0, 362 - minWidth: isChatVisible ? 380 : 0, 363 - flexBasis: isChatVisible ? 380 : 0, 364 - flexShrink: 1, 365 - borderLeftColor: "#666", 366 - borderLeftWidth: isChatVisible ? 1 : 0, 367 - overflow: "hidden", 368 - } 369 - : {} 370 - } 371 - > 372 - <Chat 373 - isChatVisible={isChatVisible} 374 - setIsChatVisible={setIsChatVisible} 375 - // chatBoxStyle={{ borderRadius: 0 }} 376 - /> 377 - <View> 378 - <ChatBox 379 - isChatVisible={isChatVisible} 380 - setIsChatVisible={setIsChatVisible} 381 - /> 382 - </View> 383 - </View> 384 - )} 385 - </View> 386 - </RNView> 387 - </SafeAreaView> 388 - </RNView> 389 - ); 390 - } 391 - async function getCurrentUserDID(): Promise<string | null> { 392 - try { 393 - const did = await storage.getItem( 394 - `${isWeb ? "@@atproto/oauth-client-browser(sub)" : "did"}`, 395 - ); 396 - if (did) { 397 - return did; 398 - } 399 - console.debug("Could not find user DID"); 400 - return null; 401 - } catch (err) { 402 - console.error("[ERROR] Failed to get current user DID:", err); 403 - return null; 404 - } 405 - }
-86
js/app/components/player/av-sync.tsx
··· 1 - export const QUIET_PROFILE = "audible"; 2 - 3 - export async function quietReceiver( 4 - mediaStream: MediaStream, 5 - playerEvent: (time: string, eventType: string, data: any) => void, 6 - ) { 7 - let audioTime = 0; 8 - let videoTime = 0; 9 - let baseline = 0; 10 - 11 - const diff = (a: number, b: number) => { 12 - if (audioTime === 0 || videoTime === 0) { 13 - return; 14 - } 15 - if (baseline === 0) { 16 - baseline = audioTime - videoTime; 17 - console.log("baseline", baseline); 18 - } 19 - console.log("diff", audioTime - videoTime - baseline); 20 - playerEvent(new Date().toISOString(), "av-sync", { 21 - diff: audioTime - videoTime - baseline, 22 - }); 23 - }; 24 - 25 - const gotVideo = (time: number) => { 26 - console.log("video", time); 27 - videoTime = time; 28 - // diff(audioTime, videoTime); 29 - }; 30 - 31 - const gotAudio = (time: number) => { 32 - console.log("audio", time); 33 - audioTime = time; 34 - diff(audioTime, videoTime); 35 - }; 36 - 37 - const Quiet = await import("quietjs-bundle"); 38 - Quiet.addReadyCallback(() => { 39 - const nav = navigator as unknown as any; 40 - // quiet doesn't let us pass in a mediaStream so we need to monkeypatch getusermedia 41 - const getUserMedia = nav.getUserMedia; 42 - nav.getUserMedia = async (constraints, cb) => { 43 - cb(mediaStream); 44 - console.log("quiet got user media"); 45 - // we're done, unmonkeypatch 46 - nav.getUserMedia = getUserMedia; 47 - }; 48 - const quiet = Quiet.receiver({ 49 - profile: QUIET_PROFILE, 50 - onReceive: (payload) => { 51 - try { 52 - const str = Quiet.ab2str(payload); 53 - const time = parseInt(str); 54 - gotAudio(time); 55 - } catch (e) { 56 - console.error("quiet receiver error", e); 57 - } 58 - }, 59 - onCreate: () => { 60 - console.log("receiver created"); 61 - }, 62 - onCreateFail: (error) => { 63 - console.error("receiver failed to create", error); 64 - }, 65 - onReceiveFail: (error) => { 66 - console.error("receiver failed to receive", error); 67 - }, 68 - // onReceiverStatsUpdate: (stats) => { 69 - // console.log("receiver stats", stats); 70 - // }, 71 - }); 72 - }); 73 - 74 - const zxing = await import("@zxing/browser"); 75 - const codeReader = new zxing.BrowserQRCodeReader(); 76 - codeReader.decodeFromStream(mediaStream, undefined, (result, err) => { 77 - try { 78 - if (result) { 79 - const time = parseInt(result.getText()); 80 - gotVideo(time); 81 - } 82 - } catch (e) { 83 - console.error("zxing error", e); 84 - } 85 - }); 86 - }
-743
js/app/components/player/controls.tsx
··· 1 - import { 2 - intoPlayerProtocol, 3 - useOffline, 4 - usePlayerStore, 5 - useRenditions, 6 - useSegment, 7 - useViewers, 8 - } from "@streamplace/components"; 9 - import { 10 - Antenna, 11 - CheckCircle, 12 - ChevronLeft, 13 - ChevronRight, 14 - Circle, 15 - Maximize, 16 - Minimize, 17 - Settings, 18 - Shell, 19 - Sparkle, 20 - Star, 21 - Volume2, 22 - VolumeX, 23 - } from "@tamagui/lucide-icons"; 24 - import { Countdown } from "components/countdown"; 25 - import Loading from "components/loading/loading"; 26 - import Viewers from "components/viewers"; 27 - import { 28 - Dispatch, 29 - Fragment, 30 - useCallback, 31 - useEffect, 32 - useRef, 33 - useState, 34 - } from "react"; 35 - import { Animated, Platform, Pressable } from "react-native"; 36 - import { useAppDispatch } from "store/hooks"; 37 - import { PlaceStreamDefs } from "streamplace"; 38 - import { 39 - Adapt, 40 - Button, 41 - H1, 42 - H3, 43 - H5, 44 - Image, 45 - ListItem, 46 - Paragraph, 47 - Popover, 48 - Separator, 49 - Slider, 50 - Text, 51 - useMedia, 52 - View, 53 - XStack, 54 - YGroup, 55 - } from "tamagui"; 56 - 57 - const PROTOCOL_HLS = "hls"; 58 - // const PROTOCOL_PROGRESSIVE_MP4 = "progressive-mp4"; 59 - // const PROTOCOL_PROGRESSIVE_WEBM = "progressive-webm"; 60 - const PROTOCOL_WEBRTC = "webrtc"; 61 - 62 - const Bar = (props) => ( 63 - <XStack 64 - paddingLeft="$3" 65 - paddingRight="$3" 66 - paddingTop="$3" 67 - paddingBottom="$3" 68 - backgroundColor="rgba(0,0,0,0.5)" 69 - position="relative" 70 - minHeight={60} 71 - {...props} 72 - /> 73 - ); 74 - 75 - const Part = (props) => ( 76 - <XStack 77 - flex={1} 78 - alignItems="center" 79 - justifyContent="center" 80 - pointerEvents="auto" 81 - {...props} 82 - /> 83 - ); 84 - 85 - const VolumeSlider = (props: { 86 - showControls: boolean; 87 - playerId: string | undefined; 88 - }) => { 89 - const muted = usePlayerStore((state) => state.muted, props.playerId); 90 - const setMuted = usePlayerStore((state) => state.setMuted, props.playerId); 91 - const volume = usePlayerStore((state) => state.volume, props.playerId); 92 - const setVolume = usePlayerStore((state) => state.setVolume, props.playerId); 93 - 94 - const [volumeVisible, setVolumeVisible] = useState(false); 95 - const [volumeSliderWidth, setVolumeSliderWidth] = useState(0); 96 - const [localVolume, setLocalVolume] = useState(volume); 97 - const sliderWidth = volumeVisible ? volumeSliderWidth : 0; 98 - const sliderOpacity = volumeVisible ? 1 : 0; 99 - 100 - const volumeSliderRef = useRef<HTMLDivElement>(null); 101 - 102 - const fadeAnim = useRef(new Animated.Value(sliderOpacity)).current; 103 - const widthAnim = useRef(new Animated.Value(sliderWidth)).current; 104 - 105 - useEffect(() => { 106 - Animated.parallel([ 107 - Animated.timing(fadeAnim, { 108 - toValue: sliderOpacity, 109 - duration: 175, 110 - useNativeDriver: false, 111 - }), 112 - Animated.timing(widthAnim, { 113 - toValue: sliderWidth, 114 - duration: 175, 115 - useNativeDriver: false, 116 - }), 117 - ]).start(); 118 - }, [fadeAnim, widthAnim, sliderOpacity, sliderWidth]); 119 - 120 - useEffect(() => { 121 - if (volumeSliderRef.current) { 122 - const rect = volumeSliderRef.current.getBoundingClientRect(); 123 - setVolumeSliderWidth(rect.width); 124 - } 125 - }, [volumeSliderRef]); 126 - 127 - useEffect(() => { 128 - setLocalVolume(volume); 129 - }, [volume]); 130 - 131 - const handleVolumeChange = useCallback( 132 - (volume: number[]) => { 133 - const newVolume = volume[0]; 134 - setLocalVolume(newVolume); 135 - setVolume(newVolume); 136 - }, 137 - [setVolume], 138 - ); 139 - 140 - const handleMuteToggle = useCallback(() => { 141 - setMuted(!muted); 142 - }, [muted, setMuted]); 143 - 144 - return ( 145 - <XStack 146 - alignItems="center" 147 - onPointerEnter={() => setVolumeVisible(true)} 148 - onPointerLeave={() => setVolumeVisible(false)} 149 - height={50} 150 - paddingRight="$3" 151 - > 152 - <Pressable 153 - onPress={handleMuteToggle} 154 - style={{ 155 - justifyContent: "center", 156 - height: "100%", 157 - }} 158 - > 159 - <View paddingLeft="$3" paddingRight="$3" justifyContent="center"> 160 - <Text>{muted ? <VolumeX /> : <Volume2 />}</Text> 161 - </View> 162 - </Pressable> 163 - {Platform.OS === "web" && ( 164 - <Animated.View 165 - style={{ 166 - opacity: fadeAnim, 167 - width: widthAnim, 168 - }} 169 - > 170 - <View 171 - ref={volumeSliderRef} 172 - width={120} 173 - paddingRight="$3" 174 - justifyContent="center" 175 - zi={20} 176 - > 177 - <Slider 178 - size="$2" 179 - value={[localVolume]} 180 - max={1} 181 - step={0.01} 182 - onValueChange={handleVolumeChange} 183 - zi={20} 184 - > 185 - <Slider.Track> 186 - <Slider.TrackActive /> 187 - </Slider.Track> 188 - <Slider.Thumb circular index={0} /> 189 - </Slider> 190 - </View> 191 - </Animated.View> 192 - )} 193 - </XStack> 194 - ); 195 - }; 196 - 197 - function isRefObject( 198 - ref: any, 199 - ): ref is 200 - | React.RefObject<HTMLVideoElement> 201 - | React.MutableRefObject<HTMLVideoElement | null> { 202 - return ref && typeof ref === "object" && "current" in ref; 203 - } 204 - 205 - export default function Controls(props: { name: string; playerId?: string }) { 206 - const playerId = props.playerId; 207 - 208 - const fullscreen = usePlayerStore((state) => state.fullscreen, playerId); 209 - const setFullscreen = usePlayerStore( 210 - (state) => state.setFullscreen, 211 - playerId, 212 - ); 213 - const setMuted = usePlayerStore((state) => state.setMuted, props.playerId); 214 - const showControls = usePlayerStore((state) => state.showControls, playerId); 215 - const setPlayTime = usePlayerStore((state) => state.setPlayTime, playerId); 216 - const offline = useOffline(); 217 - const muteWasForced = usePlayerStore( 218 - (state) => state.muteWasForced, 219 - playerId, 220 - ); 221 - const embedded = usePlayerStore((state) => state.embedded, playerId); 222 - const videoRef = usePlayerStore((state) => state.videoRef, playerId); 223 - const setUserInteraction = usePlayerStore( 224 - (state) => state.setUserInteraction, 225 - playerId, 226 - ); 227 - const isIngesting = usePlayerStore((x) => x.ingestConnectionState !== null); 228 - const pipAction = usePlayerStore((x) => x.pipAction); 229 - 230 - const fadeAnim = useRef(new Animated.Value(1)).current; 231 - 232 - let cursor = {}; 233 - if (fullscreen && !showControls) { 234 - cursor = { cursor: "none" }; 235 - } 236 - 237 - const onPress = () => { 238 - setUserInteraction(); 239 - setPlayTime(Date.now()); 240 - }; 241 - 242 - const viewers = useViewers(); 243 - const m = useMedia(); 244 - 245 - const [pipSupported, setPipSupported] = useState(false); 246 - const [pipActive, setPipActive] = useState(false); 247 - 248 - useEffect(() => { 249 - let video: HTMLVideoElement | null = null; 250 - if (isRefObject(videoRef)) { 251 - video = videoRef.current; 252 - } 253 - setPipSupported( 254 - !!document.pictureInPictureEnabled && pipAction !== undefined, 255 - ); 256 - }, [videoRef]); 257 - 258 - useEffect(() => { 259 - let video: HTMLVideoElement | null = null; 260 - if (isRefObject(videoRef)) { 261 - video = videoRef.current; 262 - } 263 - if (!video) return; 264 - function onEnter() { 265 - setPipActive(true); 266 - } 267 - function onLeave() { 268 - setPipActive(false); 269 - } 270 - video.addEventListener("enterpictureinpicture", onEnter); 271 - video.addEventListener("leavepictureinpicture", onLeave); 272 - return () => { 273 - if (video) { 274 - video.removeEventListener("enterpictureinpicture", onEnter); 275 - video.removeEventListener("leavepictureinpicture", onLeave); 276 - } 277 - }; 278 - }, [videoRef]); 279 - 280 - const handlePip = useCallback(() => { 281 - if (pipAction) pipAction(); 282 - }, [videoRef]); 283 - 284 - const userInteraction = () => { 285 - setUserInteraction(); 286 - }; 287 - 288 - return ( 289 - <View 290 - position="absolute" 291 - width="100%" 292 - height="100%" 293 - zIndex={999} 294 - flexDirection="column" 295 - justifyContent="space-between" 296 - onPointerMove={userInteraction} 297 - onTouchStart={userInteraction} 298 - onPress={onPress} 299 - {...cursor} 300 - > 301 - {muteWasForced && ( 302 - <View 303 - position="absolute" 304 - left={0} 305 - bottom={0} 306 - padding={20} 307 - opacity={showControls ? 0 : 1} 308 - > 309 - <VolumeX size={60} color="red" /> 310 - </View> 311 - )} 312 - {!offline ? null : ( 313 - <View 314 - position="absolute" 315 - width="100%" 316 - backgroundColor="black" 317 - height="100%" 318 - flex={1} 319 - justifyContent="center" 320 - alignItems="center" 321 - zIndex={1000} 322 - > 323 - <Text> 324 - <Offline /> 325 - </Text> 326 - </View> 327 - )} 328 - <Bar 329 - opacity={showControls ? (fullscreen ? 0 : 1) : 0} 330 - cursor={embedded ? "pointer" : undefined} 331 - onPress={() => { 332 - if (embedded) { 333 - // Open the current URL in a new window 334 - const u = new URL(window.location.href); 335 - u.pathname = u.pathname.replace("/embed", ""); 336 - window.open(u.toString(), "_blank"); 337 - setMuted(true); 338 - } 339 - }} 340 - > 341 - <Part justifyContent="flex-start" overflow="hidden"> 342 - <View justifyContent="center" paddingLeft="$5" maxWidth="100%"> 343 - <Text wordWrap="break-word" numberOfLines={1} ellipsizeMode="tail"> 344 - @{props.name} 345 - </Text> 346 - </View> 347 - </Part> 348 - <Part> 349 - {embedded && m.gtXs ? ( 350 - <> 351 - <Image 352 - src={require("../../assets/images/cube_small.png")} 353 - height={50} 354 - width={50} 355 - /> 356 - </> 357 - ) : null} 358 - </Part> 359 - <Part justifyContent="flex-end"> 360 - <Viewers viewers={viewers ?? 0} /> 361 - </Part> 362 - </Bar> 363 - {isIngesting && <LiveBubble playerId={playerId} />} 364 - <Bar opacity={showControls ? 1 : 0}> 365 - <Part justifyContent="flex-start"> 366 - <VolumeSlider showControls={showControls} playerId={playerId} /> 367 - </Part> 368 - <Part justifyContent="flex-end"> 369 - <PopoverMenu playerId={playerId} /> 370 - {pipSupported && ( 371 - <Pressable 372 - onPress={handlePip} 373 - disabled={pipActive} 374 - style={{ 375 - justifyContent: "center", 376 - pointerEvents: "auto", 377 - }} 378 - accessibilityLabel="Picture in Picture" 379 - > 380 - <View paddingLeft="$3" paddingRight="$3" justifyContent="center"> 381 - <svg 382 - width="24" 383 - height="24" 384 - viewBox="0 0 24 24" 385 - fill="none" 386 - stroke="currentColor" 387 - strokeWidth="2" 388 - strokeLinecap="round" 389 - strokeLinejoin="round" 390 - > 391 - <rect x="3" y="3" width="18" height="14" rx="2" /> 392 - <rect x="15" y="13" width="6" height="6" rx="1" /> 393 - </svg> 394 - </View> 395 - </Pressable> 396 - )} 397 - <Pressable 398 - style={{ 399 - justifyContent: "center", 400 - }} 401 - onPress={() => setFullscreen(!fullscreen)} 402 - > 403 - <View paddingLeft="$3" paddingRight="$5" justifyContent="center"> 404 - {fullscreen ? <Minimize /> : <Maximize />} 405 - </View> 406 - </Pressable> 407 - </Part> 408 - </Bar> 409 - </View> 410 - ); 411 - } 412 - 413 - export function PopoverMenu(props: { playerId?: string }) { 414 - const playerId = props.playerId; 415 - const [open, setOpen] = useState(false); 416 - const media = useMedia(); 417 - const renditions = useRenditions(); 418 - const selectedRendition = usePlayerStore( 419 - (x) => x.selectedRendition, 420 - playerId, 421 - ); 422 - const setSelectedRendition = usePlayerStore( 423 - (x) => x.setSelectedRendition, 424 - playerId, 425 - ); 426 - const protocol = usePlayerStore((x) => x.protocol, playerId); 427 - const setProtocol = usePlayerStore((x) => x.setProtocol, playerId); 428 - const showControls = usePlayerStore((x) => x.showControls, playerId); 429 - const dispatch = useAppDispatch(); 430 - 431 - const gearMenu = ( 432 - <GearMenu 433 - playerId={playerId} 434 - renditions={renditions} 435 - selectedRendition={selectedRendition ?? "source"} 436 - protocol={protocol} 437 - setSelectedRendition={(rendition) => { 438 - setSelectedRendition(rendition); 439 - setOpen(false); 440 - }} 441 - setProtocol={(protocol) => { 442 - setProtocol(intoPlayerProtocol(protocol)); 443 - setOpen(false); 444 - }} 445 - dispatch={dispatch} 446 - /> 447 - ); 448 - 449 - useEffect(() => { 450 - if (!media.sm && showControls === false) { 451 - setOpen(false); 452 - } 453 - }, [showControls, media.sm]); 454 - 455 - return ( 456 - <Popover 457 - size="$5" 458 - allowFlip 459 - placement="top" 460 - keepChildrenMounted 461 - stayInFrame 462 - open={open} 463 - onOpenChange={setOpen} 464 - > 465 - <Popover.Trigger asChild cursor="pointer"> 466 - <View position="relative" justifyContent="center" height={50}> 467 - <Pressable 468 - style={{ 469 - justifyContent: "center", 470 - height: "100%", 471 - }} 472 - onPress={() => setOpen(!open)} 473 - > 474 - <View paddingLeft="$3" paddingRight="$5" justifyContent="center"> 475 - <Settings /> 476 - </View> 477 - </Pressable> 478 - </View> 479 - </Popover.Trigger> 480 - 481 - <Adapt when="sm" platform="touch"> 482 - <Popover.Sheet modal dismissOnSnapToBottom snapPoints={[50]}> 483 - <Popover.Sheet.Frame padding="$2">{gearMenu}</Popover.Sheet.Frame> 484 - <Popover.Sheet.Overlay 485 - animation="lazy" 486 - enterStyle={{ opacity: 0 }} 487 - exitStyle={{ opacity: 0 }} 488 - /> 489 - </Popover.Sheet> 490 - </Adapt> 491 - 492 - <Popover.Content 493 - borderWidth={0} 494 - padding="$0" 495 - enterStyle={{ y: -10, opacity: 0 }} 496 - exitStyle={{ y: -10, opacity: 0 }} 497 - elevate 498 - userSelect="none" 499 - animation={[ 500 - "quick", 501 - { 502 - opacity: { 503 - overshootClamping: true, 504 - }, 505 - }, 506 - ]} 507 - > 508 - {gearMenu} 509 - </Popover.Content> 510 - </Popover> 511 - ); 512 - } 513 - 514 - function LiveBubble(props: { playerId?: string }) { 515 - const playerId = props.playerId; 516 - const ingestStarting = usePlayerStore((x) => x.ingestStarting, playerId); 517 - const setIngestStarting = usePlayerStore( 518 - (x) => x.setIngestStarting, 519 - playerId, 520 - ); 521 - 522 - return ( 523 - <View 524 - position="absolute" 525 - bottom={100} 526 - alignItems="center" 527 - justifyContent="center" 528 - width="100%" 529 - > 530 - <Button 531 - backgroundColor="rgba(0,0,0,0.9)" 532 - borderWidth={1} 533 - borderColor="white" 534 - borderRadius={9999999999} 535 - padding="$2" 536 - paddingLeft="$3" 537 - paddingRight="$3" 538 - onPress={() => { 539 - setIngestStarting(!ingestStarting); 540 - }} 541 - > 542 - <LiveBubbleText playerId={playerId} /> 543 - </Button> 544 - </View> 545 - ); 546 - } 547 - 548 - function LiveBubbleText(props: { playerId?: string }) { 549 - const playerId = props.playerId; 550 - const ingestStarting = usePlayerStore((x) => x.ingestStarting, playerId); 551 - const ingestConnectionState = usePlayerStore( 552 - (x) => x.ingestConnectionState, 553 - playerId, 554 - ); 555 - 556 - if (!ingestStarting) { 557 - return <H3>START STREAMING</H3>; 558 - } 559 - console.log("ingest connection state", ingestConnectionState); 560 - if (ingestConnectionState === "connected") { 561 - return ( 562 - <> 563 - <H3>LIVE</H3> 564 - <View 565 - backgroundColor="red" 566 - width={15} 567 - height={15} 568 - borderRadius={9999999999} 569 - marginLeft="$2" 570 - ></View> 571 - </> 572 - ); 573 - } 574 - return <Loading />; 575 - } 576 - 577 - function GearMenu(props: { 578 - playerId?: string; 579 - renditions: PlaceStreamDefs.Rendition[]; 580 - selectedRendition: string; 581 - protocol: string; 582 - setSelectedRendition: (rendition: string) => void; 583 - setProtocol: (protocol: string) => void; 584 - dispatch: Dispatch<any>; 585 - }) { 586 - const [menu, setMenu] = useState("root"); 587 - const { 588 - renditions, 589 - selectedRendition, 590 - protocol, 591 - setSelectedRendition, 592 - setProtocol, 593 - dispatch, 594 - } = props; 595 - 596 - return ( 597 - <YGroup alignSelf="center" bordered width={240} size="$5" borderRadius="$0"> 598 - {menu == "root" && ( 599 - <> 600 - <YGroup.Item> 601 - <ListItem 602 - hoverTheme 603 - pressTheme 604 - title="Playback Protocol" 605 - subTitle="How play?" 606 - icon={Star} 607 - iconAfter={ChevronRight} 608 - onPress={() => setMenu("protocol")} 609 - /> 610 - </YGroup.Item> 611 - <Separator /> 612 - <YGroup.Item> 613 - <ListItem 614 - hoverTheme 615 - pressTheme 616 - title="Quality" 617 - subTitle="Adjust bandwidth usage" 618 - icon={Sparkle} 619 - iconAfter={ChevronRight} 620 - onPress={() => setMenu("quality")} 621 - /> 622 - </YGroup.Item> 623 - </> 624 - )} 625 - {menu == "protocol" && ( 626 - <> 627 - <YGroup.Item> 628 - <ListItem 629 - hoverTheme 630 - pressTheme 631 - title="Back" 632 - icon={ChevronLeft} 633 - onPress={() => setMenu("root")} 634 - /> 635 - </YGroup.Item> 636 - <Separator /> 637 - <YGroup.Item> 638 - <ListItem 639 - hoverTheme 640 - pressTheme 641 - title="HLS" 642 - subTitle="HTTP Live Streaming" 643 - icon={Star} 644 - iconAfter={protocol === PROTOCOL_HLS ? CheckCircle : Circle} 645 - onPress={() => setProtocol(PROTOCOL_HLS)} 646 - /> 647 - </YGroup.Item> 648 - <Separator /> 649 - <YGroup.Item> 650 - <ListItem 651 - hoverTheme 652 - pressTheme 653 - title="WebRTC" 654 - subTitle="Lowest latency, probably" 655 - icon={Antenna} 656 - iconAfter={protocol === PROTOCOL_WEBRTC ? CheckCircle : Circle} 657 - onPress={() => setProtocol(PROTOCOL_WEBRTC)} 658 - /> 659 - </YGroup.Item> 660 - </> 661 - )} 662 - {menu == "quality" && ( 663 - <> 664 - <YGroup.Item> 665 - <ListItem 666 - hoverTheme 667 - pressTheme 668 - title="Back" 669 - icon={ChevronLeft} 670 - onPress={() => setMenu("root")} 671 - /> 672 - </YGroup.Item> 673 - <Separator /> 674 - {protocol === PROTOCOL_HLS && ( 675 - <> 676 - <YGroup.Item> 677 - <ListItem 678 - hoverTheme 679 - pressTheme 680 - title="Auto" 681 - subTitle="Automatic with HLS" 682 - icon={Star} 683 - iconAfter={ 684 - selectedRendition === "auto" ? CheckCircle : Circle 685 - } 686 - onPress={() => setSelectedRendition("auto")} 687 - /> 688 - </YGroup.Item> 689 - <Separator /> 690 - </> 691 - )} 692 - <YGroup.Item> 693 - <ListItem 694 - hoverTheme 695 - pressTheme 696 - title="Source" 697 - subTitle="Original quality" 698 - icon={Star} 699 - iconAfter={selectedRendition === "source" ? CheckCircle : Circle} 700 - onPress={() => setSelectedRendition("source")} 701 - /> 702 - </YGroup.Item> 703 - {renditions.map((rendition) => ( 704 - <Fragment key={rendition.name}> 705 - <Separator /> 706 - <YGroup.Item> 707 - <ListItem 708 - hoverTheme 709 - pressTheme 710 - title={rendition.name} 711 - subTitle={rendition.name} 712 - icon={Shell} 713 - iconAfter={ 714 - selectedRendition === rendition.name ? CheckCircle : Circle 715 - } 716 - onPress={() => setSelectedRendition(rendition.name)} 717 - /> 718 - </YGroup.Item> 719 - </Fragment> 720 - ))} 721 - </> 722 - )} 723 - </YGroup> 724 - ); 725 - } 726 - 727 - export function Offline() { 728 - const segment = useSegment(); 729 - return ( 730 - <View flex={1} justifyContent="center" alignItems="center"> 731 - <View flexDirection="row"> 732 - <H1 paddingRight="$3">Offline</H1> 733 - </View> 734 - {segment && ( 735 - <> 736 - <Paragraph>Playback will start automatically</Paragraph> 737 - <H5>Last seen:</H5> 738 - <Countdown from={segment.startTime} small={true} /> 739 - </> 740 - )} 741 - </View> 742 - ); 743 - }
-207
js/app/components/player/fullscreen.native.tsx
··· 1 - import { useNavigation } from "@react-navigation/native"; 2 - import { 3 - PlayerProtocol, 4 - useLivestreamStore, 5 - usePlayerStore, 6 - } from "@streamplace/components"; 7 - import { VideoView } from "expo-video"; 8 - import { useEffect, useRef, useState } from "react"; 9 - import { BackHandler, Dimensions, StatusBar, StyleSheet } from "react-native"; 10 - import { useSafeAreaInsets } from "react-native-safe-area-context"; 11 - import { View } from "tamagui"; 12 - import Controls from "./controls"; 13 - import PlayerLoading from "./player-loading"; 14 - import VideoRetry from "./video-retry"; 15 - import Video from "./video.native"; 16 - 17 - // Standard 16:9 video aspect ratio 18 - const VIDEO_ASPECT_RATIO = 16 / 9; 19 - 20 - export function Fullscreen(props: { src: string; playerId?: string }) { 21 - const ref = useRef<VideoView>(null); 22 - const insets = useSafeAreaInsets(); 23 - const navigation = useNavigation(); 24 - const [dimensions, setDimensions] = useState(Dimensions.get("window")); 25 - 26 - // Get state from player store 27 - const protocol = usePlayerStore((x) => x.protocol); 28 - const fullscreen = usePlayerStore((x) => x.fullscreen); 29 - const setFullscreen = usePlayerStore((x) => x.setFullscreen); 30 - const handle = useLivestreamStore((x) => x.profile?.handle); 31 - 32 - const setSrc = usePlayerStore((x) => x.setSrc); 33 - 34 - useEffect(() => { 35 - setSrc(props.src); 36 - }, [props.src]); 37 - 38 - // Re-calculate dimensions on orientation change 39 - useEffect(() => { 40 - const updateDimensions = () => { 41 - setDimensions(Dimensions.get("window")); 42 - }; 43 - 44 - const subscription = Dimensions.addEventListener( 45 - "change", 46 - updateDimensions, 47 - ); 48 - 49 - return () => { 50 - subscription.remove(); 51 - }; 52 - }, []); 53 - 54 - // Hide status bar when in fullscreen mode 55 - useEffect(() => { 56 - if (fullscreen) { 57 - StatusBar.setHidden(true); 58 - console.log("setting sidebar hidden"); 59 - 60 - // Hide the navigation header 61 - navigation.setOptions({ 62 - headerShown: false, 63 - }); 64 - 65 - // Handle hardware back button 66 - const backHandler = BackHandler.addEventListener( 67 - "hardwareBackPress", 68 - () => { 69 - setFullscreen(false); 70 - return true; 71 - }, 72 - ); 73 - 74 - return () => { 75 - backHandler.remove(); 76 - }; 77 - } else { 78 - StatusBar.setHidden(false); 79 - 80 - // Restore the navigation header 81 - navigation.setOptions({ 82 - headerShown: true, 83 - }); 84 - } 85 - 86 - return () => { 87 - StatusBar.setHidden(false); 88 - // Ensure header is restored if component unmounts 89 - navigation.setOptions({ 90 - headerShown: true, 91 - }); 92 - }; 93 - }, [fullscreen, navigation, setFullscreen]); 94 - 95 - // Handle fullscreen state changes for native video players 96 - useEffect(() => { 97 - // For WebRTC, we handle fullscreen manually via the custom implementation 98 - if (protocol === PlayerProtocol.WEBRTC) { 99 - return; 100 - } 101 - 102 - // For HLS and other protocols, sync with native fullscreen 103 - if (ref.current) { 104 - if (fullscreen) { 105 - ref.current.enterFullscreen(); 106 - } else { 107 - ref.current.exitFullscreen(); 108 - } 109 - } 110 - }, [fullscreen, protocol]); 111 - 112 - if (fullscreen && protocol === PlayerProtocol.WEBRTC) { 113 - // Determine if we're in landscape mode 114 - const isLandscape = dimensions.width > dimensions.height; 115 - 116 - // Calculate video container dimensions based on screen size and orientation 117 - let videoWidth: number; 118 - let videoHeight: number; 119 - 120 - if (isLandscape) { 121 - // In landscape, account for safe areas and use available height 122 - const availableHeight = dimensions.height - (insets.top + insets.bottom); 123 - const availableWidth = dimensions.width - (insets.left + insets.right); 124 - 125 - videoHeight = availableHeight; 126 - videoWidth = videoHeight * VIDEO_ASPECT_RATIO; 127 - 128 - // If calculated width exceeds available width, constrain and maintain aspect ratio 129 - if (videoWidth > availableWidth) { 130 - videoWidth = availableWidth; 131 - videoHeight = videoWidth / VIDEO_ASPECT_RATIO; 132 - } 133 - } else { 134 - // In portrait, account for safe areas 135 - const availableWidth = dimensions.width - (insets.left + insets.right); 136 - videoWidth = availableWidth; 137 - videoHeight = videoWidth / VIDEO_ASPECT_RATIO; 138 - } 139 - 140 - // Calculate position to center the video, accounting for safe areas 141 - const leftPosition = (dimensions.width - videoWidth) / 2; 142 - const topPosition = (dimensions.height - videoHeight) / 2; 143 - 144 - // When in custom fullscreen mode 145 - return ( 146 - <View 147 - style={[ 148 - styles.fullscreenContainer, 149 - { 150 - width: dimensions.width, 151 - height: dimensions.height, 152 - }, 153 - ]} 154 - > 155 - <View 156 - style={[ 157 - styles.videoContainer, 158 - { 159 - width: videoWidth, 160 - height: videoHeight, 161 - left: leftPosition, 162 - top: topPosition, 163 - }, 164 - ]} 165 - > 166 - <VideoRetry> 167 - <Video /> 168 - </VideoRetry> 169 - <PlayerLoading /> 170 - <Controls name={handle || "Streaming"} playerId={props.playerId} /> 171 - </View> 172 - </View> 173 - ); 174 - } 175 - 176 - // Normal non-fullscreen mode 177 - return ( 178 - <> 179 - <PlayerLoading /> 180 - <Controls name={handle || ""} playerId={props.playerId} /> 181 - <VideoRetry> 182 - <Video /> 183 - </VideoRetry> 184 - </> 185 - ); 186 - } 187 - 188 - const styles = StyleSheet.create({ 189 - fullscreenContainer: { 190 - position: "absolute", 191 - top: 0, 192 - left: 0, 193 - right: 0, 194 - bottom: 0, 195 - backgroundColor: "#000", 196 - zIndex: 9999, 197 - margin: 0, 198 - padding: 0, 199 - justifyContent: "center", 200 - alignItems: "center", 201 - }, 202 - videoContainer: { 203 - position: "absolute", 204 - backgroundColor: "#111", 205 - overflow: "hidden", 206 - }, 207 - });
-87
js/app/components/player/fullscreen.tsx
··· 1 - import { useLivestreamStore, usePlayerStore } from "@streamplace/components"; 2 - import { useEffect, useRef } from "react"; 3 - import { TamaguiElement, View } from "tamagui"; 4 - import Controls from "./controls"; 5 - import PlayerLoading from "./player-loading"; 6 - import Video from "./video"; 7 - import VideoRetry from "./video-retry"; 8 - 9 - export function Fullscreen(props: { playerId: string; src: string }) { 10 - const playerId = props.playerId; 11 - const protocol = usePlayerStore((x) => x.protocol, playerId); 12 - const fullscreen = usePlayerStore((x) => x.fullscreen, playerId); 13 - const setFullscreen = usePlayerStore((x) => x.setFullscreen, playerId); 14 - const setSrc = usePlayerStore((x) => x.setSrc); 15 - 16 - const handle = useLivestreamStore((x) => x.profile?.handle); 17 - 18 - const divRef = useRef<TamaguiElement>(null); 19 - const videoRef = useRef<HTMLVideoElement | null>(null); 20 - 21 - useEffect(() => { 22 - setSrc(props.src); 23 - }, [props.src]); 24 - 25 - useEffect(() => { 26 - if (!divRef.current) { 27 - return; 28 - } 29 - (async () => { 30 - if (fullscreen && !document.fullscreenElement) { 31 - try { 32 - const div = divRef.current as HTMLDivElement; 33 - if (typeof div.requestFullscreen === "function") { 34 - await div.requestFullscreen(); 35 - } else if (videoRef.current) { 36 - if ( 37 - typeof (videoRef.current as any).webkitEnterFullscreen === 38 - "function" 39 - ) { 40 - await (videoRef.current as any).webkitEnterFullscreen(); 41 - } else if ( 42 - typeof videoRef.current.requestFullscreen === "function" 43 - ) { 44 - await videoRef.current.requestFullscreen(); 45 - } 46 - } 47 - setFullscreen(true); 48 - } catch (e) { 49 - console.error("fullscreen failed", e.message); 50 - } 51 - } 52 - if (!fullscreen) { 53 - if (document.fullscreenElement) { 54 - try { 55 - await document.exitFullscreen(); 56 - } catch (e) { 57 - console.error("fullscreen exit failed", e.message); 58 - } 59 - } 60 - setFullscreen(false); 61 - } 62 - })(); 63 - }, [fullscreen, protocol]); 64 - 65 - useEffect(() => { 66 - const listener = () => { 67 - console.log("fullscreenchange", document.fullscreenElement); 68 - setFullscreen(!!document.fullscreenElement); 69 - }; 70 - document.body.addEventListener("fullscreenchange", listener); 71 - document.body.addEventListener("webkitfullscreenchange", listener); 72 - return () => { 73 - document.body.removeEventListener("fullscreenchange", listener); 74 - document.body.removeEventListener("webkitfullscreenchange", listener); 75 - }; 76 - }, []); 77 - 78 - return ( 79 - <View flex={1} ref={divRef}> 80 - <PlayerLoading /> 81 - <Controls name={handle || "Streaming"} playerId={props.playerId} /> 82 - <VideoRetry> 83 - <Video /> 84 - </VideoRetry> 85 - </View> 86 - ); 87 - }
-36
js/app/components/player/player-loading.tsx
··· 1 - import { 2 - KeepAwake, 3 - PlayerStatus, 4 - usePlayerStore, 5 - } from "@streamplace/components"; 6 - import { Play } from "@tamagui/lucide-icons"; 7 - import { Spinner } from "components/loading/loading"; 8 - import { useTheme, View } from "tamagui"; 9 - 10 - export default function PlayerLoading() { 11 - const status = usePlayerStore((x) => x.status); 12 - const theme = useTheme(); 13 - 14 - if (status === PlayerStatus.PLAYING) { 15 - return <KeepAwake />; 16 - } 17 - 18 - let spinner = <Spinner></Spinner>; 19 - if (status === PlayerStatus.PAUSE) { 20 - spinner = <Play size="$12" color={theme.accentColor.val} />; 21 - } 22 - 23 - return ( 24 - <View 25 - position="absolute" 26 - width="100%" 27 - height="100%" 28 - zIndex={998} 29 - alignItems="center" 30 - justifyContent="center" 31 - backgroundColor="rgba(0,0,0,0.8)" 32 - > 33 - {spinner} 34 - </View> 35 - ); 36 - }
-135
js/app/components/player/player.tsx
··· 1 - import { 2 - getFirstPlayerID, 3 - LivestreamProvider, 4 - PlayerProvider, 5 - PlayerStatus, 6 - PlayerStatusTracker, 7 - usePlayerStore, 8 - useSegment, 9 - useStreamplaceStore, 10 - } from "@streamplace/components"; 11 - import { useEffect, useState } from "react"; 12 - import { Text, View } from "tamagui"; 13 - import { Fullscreen } from "./fullscreen"; 14 - import { PlayerProps } from "./props"; 15 - 16 - const OFFLINE_THRESHOLD = 10000; 17 - 18 - export function Player( 19 - props: Partial<PlayerProps> & { 20 - setFullscreen?: (fullscreen: boolean) => void; 21 - }, 22 - ) { 23 - return ( 24 - <LivestreamProvider src={props.src ?? ""}> 25 - <PlayerProvider defaultId={props.playerId || undefined}> 26 - <PropUpFullscreen setFullscreen={props.setFullscreen} /> 27 - <PlayerInner {...props} /> 28 - </PlayerProvider> 29 - </LivestreamProvider> 30 - ); 31 - } 32 - 33 - export function PropUpFullscreen(props: { 34 - setFullscreen?: (fullscreen: boolean) => void; 35 - ingest?: boolean; 36 - }) { 37 - const fullscreen = usePlayerStore((x) => x.fullscreen); 38 - 39 - useEffect(() => { 40 - if (props.setFullscreen) { 41 - props.setFullscreen(fullscreen); 42 - } 43 - }, [fullscreen, props.setFullscreen]); 44 - 45 - return <></>; 46 - } 47 - 48 - export function PlayerInner(props: Partial<PlayerProps>) { 49 - // Will get the first player ID from the store 50 - const playerId = getFirstPlayerID(); 51 - 52 - const setIngest = usePlayerStore((x) => x.setIngestConnectionState); 53 - 54 - const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout); 55 - 56 - // Will call back every few seconds to send health updates 57 - usePlayerStatus(); 58 - 59 - useEffect(() => { 60 - setIngest(props.ingest ? "new" : null); 61 - }, []); 62 - 63 - if (typeof props.src !== "string") { 64 - return ( 65 - <View> 66 - <Text>No source provided 🤷</Text> 67 - </View> 68 - ); 69 - } 70 - 71 - useEffect(() => { 72 - return () => { 73 - clearControlsTimeout(); 74 - }; 75 - }, []); 76 - 77 - const segment = useSegment(); 78 - const [lastCheck, setLastCheck] = useState(0); 79 - 80 - return ( 81 - <View f={1} justifyContent="center" position="relative"> 82 - <Fullscreen playerId={playerId} src={props.src}></Fullscreen> 83 - </View> 84 - ); 85 - } 86 - 87 - const POLL_INTERVAL = 5000; 88 - export function usePlayerStatus(): [PlayerStatus] { 89 - const playerStatus = usePlayerStore((x) => x.status); 90 - const url = useStreamplaceStore((x) => x.url); 91 - const playerEvent = usePlayerStore((x) => x.playerEvent); 92 - const [whatDoing, setWhatDoing] = useState<PlayerStatus>(PlayerStatus.START); 93 - const [whatDid, setWhatDid] = useState<PlayerStatusTracker>({}); 94 - const [doingSince, setDoingSince] = useState(Date.now()); 95 - const [lastUpdated, setLastUpdated] = useState(0); 96 - const updateWhatDid = (now: Date): PlayerStatusTracker => { 97 - const prev = whatDid[whatDoing] ?? 0; 98 - const duration = now.getTime() - doingSince; 99 - const ret = { 100 - ...whatDid, 101 - [whatDoing]: prev + duration, 102 - }; 103 - return ret; 104 - }; 105 - // callback to update the status 106 - useEffect(() => { 107 - const now = new Date(); 108 - if (playerStatus !== whatDoing) { 109 - setWhatDid(updateWhatDid(now)); 110 - setWhatDoing(playerStatus); 111 - setDoingSince(now.getTime()); 112 - } 113 - }, [playerStatus]); 114 - 115 - useEffect(() => { 116 - if (lastUpdated === 0) { 117 - return; 118 - } 119 - const now = new Date(); 120 - const fullWhatDid = updateWhatDid(now); 121 - setWhatDid({} as PlayerStatusTracker); 122 - setDoingSince(now.getTime()); 123 - playerEvent(url, now.toISOString(), "aq-played", { 124 - whatHappened: fullWhatDid, 125 - }); 126 - }, [lastUpdated]); 127 - 128 - useEffect(() => { 129 - const interval = setInterval((_) => { 130 - setLastUpdated(Date.now()); 131 - }, POLL_INTERVAL); 132 - return () => clearInterval(interval); 133 - }, []); 134 - return [whatDoing]; 135 - }
-57
js/app/components/player/shared.tsx
··· 1 - import { PlayerProtocol } from "@streamplace/components"; 2 - import useStreamplaceNode from "hooks/useStreamplaceNode"; 3 - import { useMemo } from "react"; 4 - 5 - const protocolSuffixes = { 6 - m3u8: PlayerProtocol.HLS, 7 - mp4: PlayerProtocol.PROGRESSIVE_MP4, 8 - webm: PlayerProtocol.PROGRESSIVE_WEBM, 9 - webrtc: PlayerProtocol.WEBRTC, 10 - }; 11 - 12 - export function srcToUrl( 13 - props: { 14 - src: string; 15 - selectedRendition?: string; 16 - }, 17 - protocol: PlayerProtocol, 18 - ): { 19 - url: string; 20 - protocol: string; 21 - } { 22 - const { url } = useStreamplaceNode(); 23 - return useMemo(() => { 24 - if (props.src.startsWith("http://") || props.src.startsWith("https://")) { 25 - const segments = props.src.split(/[./]/); 26 - const suffix = segments[segments.length - 1]; 27 - if (protocolSuffixes[suffix]) { 28 - return { 29 - url: props.src, 30 - protocol: protocolSuffixes[suffix], 31 - }; 32 - } else { 33 - throw new Error(`unknown playback protocol: ${suffix}`); 34 - } 35 - } 36 - let outUrl: string; 37 - if (protocol === PlayerProtocol.HLS) { 38 - if (props.selectedRendition === "auto") { 39 - outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8`; 40 - } else { 41 - outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8?rendition=${props.selectedRendition || "source"}`; 42 - } 43 - } else if (protocol === PlayerProtocol.PROGRESSIVE_MP4) { 44 - outUrl = `${url}/api/playback/${props.src}/stream.mp4`; 45 - } else if (protocol === PlayerProtocol.PROGRESSIVE_WEBM) { 46 - outUrl = `${url}/api/playback/${props.src}/stream.webm`; 47 - } else if (protocol === PlayerProtocol.WEBRTC) { 48 - outUrl = `${url}/api/playback/${props.src}/webrtc?rendition=${props.selectedRendition || "source"}`; 49 - } else { 50 - throw new Error(`unknown playback protocol: ${protocol}`); 51 - } 52 - return { 53 - protocol: protocol, 54 - url: outUrl, 55 - }; 56 - }, [props.src, props.selectedRendition, protocol, url]); 57 - }
-255
js/app/components/player/use-webrtc.tsx
··· 1 - import { usePlayerStore } from "@streamplace/components"; 2 - import { 3 - createStreamKeyRecord, 4 - selectStoredKey, 5 - } from "features/bluesky/blueskySlice"; 6 - import { useEffect, useRef, useState } from "react"; 7 - import { useAppDispatch, useAppSelector } from "store/hooks"; 8 - import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; 9 - 10 - export default function useWebRTC( 11 - endpoint: string, 12 - ): [MediaStream | null, boolean] { 13 - const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 14 - const [stuck, setStuck] = useState<boolean>(false); 15 - 16 - const lastChange = useRef<number>(0); 17 - 18 - useEffect(() => { 19 - const peerConnection = new RTCPeerConnection({ 20 - bundlePolicy: "max-bundle", 21 - }); 22 - peerConnection.addTransceiver("video", { 23 - direction: "recvonly", 24 - }); 25 - peerConnection.addTransceiver("audio", { 26 - direction: "recvonly", 27 - }); 28 - peerConnection.addEventListener("track", (event) => { 29 - const track = event.track; 30 - if (!track) { 31 - return; 32 - } 33 - setMediaStream(event.streams[0]); 34 - }); 35 - peerConnection.addEventListener("connectionstatechange", () => { 36 - console.log("connection state change", peerConnection.connectionState); 37 - if (peerConnection.connectionState === "closed") { 38 - setStuck(true); 39 - } 40 - if (peerConnection.connectionState !== "connected") { 41 - return; 42 - } 43 - }); 44 - peerConnection.addEventListener("negotiationneeded", () => { 45 - negotiateConnectionWithClientOffer(peerConnection, endpoint); 46 - }); 47 - 48 - let lastFramesReceived = 0; 49 - let lastAudioFramesReceived = 0; 50 - 51 - const handle = setInterval(async () => { 52 - const stats = await peerConnection.getStats(); 53 - stats.forEach((stat) => { 54 - const mediaType = stat.mediaType /* web */ ?? stat.kind; /* native */ 55 - if (stat.type === "inbound-rtp" && mediaType === "audio") { 56 - const audioFramesReceived = stat.lastPacketReceivedTimestamp; 57 - if (lastAudioFramesReceived !== audioFramesReceived) { 58 - lastAudioFramesReceived = audioFramesReceived; 59 - lastChange.current = Date.now(); 60 - setStuck(false); 61 - } 62 - } 63 - if (stat.type === "inbound-rtp" && mediaType === "video") { 64 - const framesReceived = stat.framesReceived; 65 - if (lastFramesReceived !== framesReceived) { 66 - lastFramesReceived = framesReceived; 67 - lastChange.current = Date.now(); 68 - setStuck(false); 69 - } 70 - } 71 - }); 72 - if (Date.now() - lastChange.current > 2000) { 73 - setStuck(true); 74 - } 75 - }, 200); 76 - 77 - return () => { 78 - clearInterval(handle); 79 - peerConnection.close(); 80 - }; 81 - }, [endpoint]); 82 - return [mediaStream, stuck]; 83 - } 84 - 85 - /** 86 - * Performs the actual SDP exchange. 87 - * 88 - * 1. Constructs the client's SDP offer 89 - * 2. Sends the SDP offer to the server, 90 - * 3. Awaits the server's offer. 91 - * 92 - * SDP describes what kind of media we can send and how the server and client communicate. 93 - * 94 - * https://developer.mozilla.org/en-US/docs/Glossary/SDP 95 - * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation 96 - */ 97 - export async function negotiateConnectionWithClientOffer( 98 - peerConnection: RTCPeerConnection, 99 - endpoint: string, 100 - bearerToken?: string, 101 - ) { 102 - /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ 103 - const offer = await peerConnection.createOffer({ 104 - offerToReceiveAudio: true, 105 - offerToReceiveVideo: true, 106 - }); 107 - /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */ 108 - await peerConnection.setLocalDescription(offer); 109 - 110 - /** Wait for ICE gathering to complete */ 111 - let ofr = await waitToCompleteICEGathering(peerConnection); 112 - if (!ofr) { 113 - throw Error("failed to gather ICE candidates for offer"); 114 - } 115 - 116 - /** 117 - * As long as the connection is open, attempt to... 118 - */ 119 - while (peerConnection.connectionState !== "closed") { 120 - try { 121 - /** 122 - * This response contains the server's SDP offer. 123 - * This specifies how the client should communicate, 124 - * and what kind of media client and server have negotiated to exchange. 125 - */ 126 - let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken); 127 - if (response.status === 201) { 128 - let answerSDP = await response.text(); 129 - if ((peerConnection.connectionState as string) === "closed") { 130 - return; 131 - } 132 - await peerConnection.setRemoteDescription( 133 - new RTCSessionDescription({ type: "answer", sdp: answerSDP }), 134 - ); 135 - return response.headers.get("Location"); 136 - } else if (response.status === 405) { 137 - console.log( 138 - "Remember to update the URL passed into the WHIP or WHEP client", 139 - ); 140 - } else { 141 - const errorMessage = await response.text(); 142 - console.error(errorMessage); 143 - } 144 - } catch (e) { 145 - console.error(`posting sdp offer failed: ${e}`); 146 - } 147 - 148 - /** Limit reconnection attempts to at-most once every 5 seconds */ 149 - await new Promise((r) => setTimeout(r, 5000)); 150 - } 151 - } 152 - 153 - async function postSDPOffer( 154 - endpoint: string, 155 - data: string, 156 - bearerToken?: string, 157 - ) { 158 - return await fetch(endpoint, { 159 - method: "POST", 160 - mode: "cors", 161 - headers: { 162 - "content-type": "application/sdp", 163 - ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), 164 - }, 165 - body: data, 166 - }); 167 - } 168 - 169 - /** 170 - * Receives an RTCPeerConnection and waits until 171 - * the connection is initialized or a timeout passes. 172 - * 173 - * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#section-4.1 174 - * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState 175 - * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icegatheringstatechange_event 176 - */ 177 - async function waitToCompleteICEGathering(peerConnection: RTCPeerConnection) { 178 - return new Promise<RTCSessionDescription | null>((resolve) => { 179 - /** Wait at most 1 second for ICE gathering. */ 180 - setTimeout(function () { 181 - if (peerConnection.connectionState === "closed") { 182 - return; 183 - } 184 - resolve(peerConnection.localDescription); 185 - }, 1000); 186 - peerConnection.addEventListener("icegatheringstatechange", (ev) => { 187 - if (peerConnection.iceGatheringState === "complete") { 188 - resolve(peerConnection.localDescription); 189 - } 190 - }); 191 - }); 192 - } 193 - 194 - export function useWebRTCIngest({ 195 - endpoint, 196 - }: { 197 - endpoint: string; 198 - }): [MediaStream | null, (mediaStream: MediaStream | null) => void] { 199 - const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 200 - const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); 201 - const setIngestConnectionState = usePlayerStore( 202 - (x) => x.setIngestConnectionState, 203 - ); 204 - const dispatch = useAppDispatch(); 205 - const storedKey = useAppSelector(selectStoredKey)?.privateKey; 206 - 207 - const [retryTime, setRetryTime] = useState<number>(0); 208 - useEffect(() => { 209 - if (storedKey) { 210 - return; 211 - } 212 - dispatch(createStreamKeyRecord({ store: true })); 213 - }, [storedKey]); 214 - useEffect(() => { 215 - if (!mediaStream) { 216 - return; 217 - } 218 - if (!storedKey) { 219 - return; 220 - } 221 - console.log("creating peer connection"); 222 - const peerConnection = new RTCPeerConnection({ 223 - bundlePolicy: "max-bundle", 224 - }); 225 - for (const track of mediaStream.getTracks()) { 226 - console.log( 227 - "adding track", 228 - track.kind, 229 - track.label, 230 - track.enabled, 231 - track.readyState, 232 - ); 233 - peerConnection.addTrack(track, mediaStream); 234 - } 235 - peerConnection.addEventListener("connectionstatechange", (ev) => { 236 - setIngestConnectionState(peerConnection.connectionState); 237 - console.log("connection state change", peerConnection.connectionState); 238 - if (peerConnection.connectionState === "failed") { 239 - setRetryTime(Date.now()); 240 - } 241 - }); 242 - peerConnection.addEventListener("negotiationneeded", (ev) => { 243 - negotiateConnectionWithClientOffer(peerConnection, endpoint, storedKey); 244 - }); 245 - 246 - peerConnection.addEventListener("track", (ev) => { 247 - console.log(ev); 248 - }); 249 - 250 - return () => { 251 - peerConnection.close(); 252 - }; 253 - }, [endpoint, mediaStream, storedKey, retryTime]); 254 - return [mediaStream, setMediaStream]; 255 - }
-36
js/app/components/player/video-retry.tsx
··· 1 - import { useOffline } from "@streamplace/components"; 2 - import React, { useEffect, useRef, useState } from "react"; 3 - 4 - export default function VideoRetry(props: { children: React.ReactNode }) { 5 - const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null); 6 - const [retries, setRetries] = useState(0); 7 - const [hasStarted, setHasStarted] = useState(false); 8 - 9 - const offline = useOffline(); 10 - 11 - useEffect(() => { 12 - if (!offline && !hasStarted) { 13 - console.log("Player is online. Marking as started."); 14 - setHasStarted(true); 15 - } 16 - 17 - if (offline) { 18 - console.log("Player is offline. Incrementing retries."); 19 - setRetries((prevRetries) => prevRetries + 1); 20 - 21 - const jitter = 500 + Math.random() * 1500; 22 - retryTimeoutRef.current = setTimeout(() => { 23 - console.log("Retrying video playback..."); 24 - }, jitter); 25 - } 26 - 27 - return () => { 28 - if (retryTimeoutRef.current) { 29 - console.log("Clearing retry timeout"); 30 - clearTimeout(retryTimeoutRef.current); 31 - } 32 - }; 33 - }, [offline, hasStarted]); 34 - 35 - return <React.Fragment key={retries}>{props.children}</React.Fragment>; 36 - }
-188
js/app/components/player/video.native.tsx
··· 1 - import { 2 - PlayerProtocol, 3 - PlayerStatus, 4 - usePlayerStore, 5 - useStreamplaceStore, 6 - } from "@streamplace/components"; 7 - import { useVideoPlayer, VideoPlayerEvents, VideoView } from "expo-video"; 8 - import { useEffect, useRef } from "react"; 9 - import { MediaStream, RTCPIPView, RTCView } from "react-native-webrtc"; 10 - import { View } from "tamagui"; 11 - import { srcToUrl } from "./shared"; 12 - import useWebRTC from "./use-webrtc"; 13 - 14 - export default function VideoNative() { 15 - const protocol = usePlayerStore((x) => x.protocol); 16 - if (protocol === PlayerProtocol.WEBRTC) { 17 - return <NativeWHEP />; 18 - } else { 19 - return <NativeVideo />; 20 - } 21 - } 22 - 23 - export function NativeVideo() { 24 - const protocol = usePlayerStore((x) => x.protocol); 25 - 26 - const selectedRendition = usePlayerStore((x) => x.selectedRendition); 27 - const src = usePlayerStore((x) => x.src); 28 - const { url } = srcToUrl({ src: src, selectedRendition }, protocol); 29 - const setStatus = usePlayerStore((x) => x.setStatus); 30 - const muted = usePlayerStore((x) => x.muted); 31 - const volume = usePlayerStore((x) => x.volume); 32 - const setFullscreen = usePlayerStore((x) => x.setFullscreen); 33 - const fullscreen = usePlayerStore((x) => x.fullscreen); 34 - const playerEvent = usePlayerStore((x) => x.playerEvent); 35 - const spurl = useStreamplaceStore((x) => x.url); 36 - 37 - useEffect(() => { 38 - return () => { 39 - setStatus(PlayerStatus.START); 40 - }; 41 - }, [setStatus]); 42 - 43 - const player = useVideoPlayer(url, (player) => { 44 - player.loop = true; 45 - player.muted = muted; 46 - player.play(); 47 - }); 48 - 49 - useEffect(() => { 50 - player.muted = muted; 51 - }, [muted, player]); 52 - 53 - useEffect(() => { 54 - player.volume = volume; 55 - }, [volume, player]); 56 - 57 - useEffect(() => { 58 - const subs = ( 59 - [ 60 - "playToEnd", 61 - "playbackRateChange", 62 - "playingChange", 63 - "sourceChange", 64 - "statusChange", 65 - "volumeChange", 66 - ] as (keyof VideoPlayerEvents)[] 67 - ).map((evType) => { 68 - const now = new Date(); 69 - return player.addListener(evType, (...args) => { 70 - playerEvent(spurl, now.toISOString(), evType, { args: args }); 71 - }); 72 - }); 73 - 74 - subs.push( 75 - player.addListener("playingChange", (newIsPlaying) => { 76 - if (newIsPlaying) { 77 - setStatus(PlayerStatus.PLAYING); 78 - } else { 79 - setStatus(PlayerStatus.WAITING); 80 - } 81 - }), 82 - ); 83 - 84 - return () => { 85 - for (const sub of subs) { 86 - sub.remove(); 87 - } 88 - }; 89 - }, [player, playerEvent, setStatus]); 90 - 91 - return ( 92 - <VideoView 93 - style={{ flex: 1, backgroundColor: "#111" }} 94 - //ref={props.nativeVideoRef} 95 - player={player} 96 - allowsFullscreen 97 - nativeControls={fullscreen} 98 - onFullscreenEnter={() => { 99 - setFullscreen(true); 100 - }} 101 - onFullscreenExit={() => { 102 - setFullscreen(false); 103 - }} 104 - // allowsPictureInPicture 105 - // startsPictureInPictureAutomatically 106 - /> 107 - ); 108 - } 109 - 110 - export function NativeWHEP() { 111 - const selectedRendition = usePlayerStore((x) => x.selectedRendition); 112 - const src = usePlayerStore((x) => x.src); 113 - const { url } = srcToUrl( 114 - { src: src, selectedRendition }, 115 - PlayerProtocol.WEBRTC, 116 - ); 117 - const rtcView = useRef<typeof RTCPIPView>(null); 118 - const [stream, stuck] = useWebRTC(url); 119 - 120 - const setStatus = usePlayerStore((x) => x.setStatus); 121 - const muted = usePlayerStore((x) => x.muted); 122 - const volume = usePlayerStore((x) => x.volume); 123 - 124 - console.log("native whep rendered"); 125 - 126 - useEffect(() => { 127 - if (stuck) { 128 - setStatus(PlayerStatus.STALLED); 129 - } else { 130 - setStatus(PlayerStatus.PLAYING); 131 - } 132 - }, [stuck, setStatus]); 133 - 134 - const mediaStream = stream as unknown as MediaStream; 135 - 136 - useEffect(() => { 137 - if (!mediaStream) { 138 - setStatus(PlayerStatus.WAITING); 139 - return; 140 - } 141 - setStatus(PlayerStatus.PLAYING); 142 - }, [mediaStream, setStatus]); 143 - 144 - useEffect(() => { 145 - if (!mediaStream) { 146 - return; 147 - } 148 - mediaStream.getTracks().forEach((track) => { 149 - if (track.kind === "audio") { 150 - track._setVolume(muted ? 0 : volume); 151 - } 152 - }); 153 - }, [mediaStream, muted, volume]); 154 - 155 - if (!mediaStream) { 156 - return <View></View>; 157 - } 158 - 159 - let pipOptions = { 160 - startAutomatically: true, 161 - fallbackView: ( 162 - <View style={{ height: 50, width: 50, backgroundColor: "red" }} /> 163 - ) as any, 164 - preferredSize: { 165 - width: 854, 166 - height: 480, 167 - }, 168 - }; 169 - 170 - return ( 171 - <RTCView 172 - ref={rtcView as any} 173 - mirror={false} 174 - objectFit={"contain"} 175 - streamURL={mediaStream.toURL()} 176 - style={{ 177 - backgroundColor: "#111", 178 - flex: 1, 179 - }} 180 - pictureInPictureEnabled={true} 181 - autoStartPictureInPicture={true} 182 - pictureInPicturePreferredSize={{ 183 - width: 160, 184 - height: 90, 185 - }} 186 - /> 187 - ); 188 - }
-572
js/app/components/player/video.tsx
··· 1 - import { 2 - IngestMediaSource, 3 - PlayerProtocol, 4 - PlayerStatus, 5 - usePlayerStore, 6 - useStreamplaceStore, 7 - } from "@streamplace/components"; 8 - import streamKey from "components/live-dashboard/stream-key"; 9 - import Hls from "hls.js"; 10 - import useStreamplaceNode from "hooks/useStreamplaceNode"; 11 - import { 12 - ForwardedRef, 13 - forwardRef, 14 - useCallback, 15 - useEffect, 16 - useRef, 17 - useState, 18 - } from "react"; 19 - import { Text, View } from "tamagui"; 20 - import { srcToUrl } from "./shared"; 21 - import useWebRTC, { useWebRTCIngest } from "./use-webrtc"; 22 - import { 23 - logWebRTCDiagnostics, 24 - useWebRTCDiagnostics, 25 - } from "./webrtc-diagnostics"; 26 - import { checkWebRTCSupport } from "./webrtc-primitives"; 27 - 28 - type VideoProps = { url: string }; 29 - 30 - export default function WebVideo() { 31 - const inProto = usePlayerStore((x) => x.protocol); 32 - const isIngesting = usePlayerStore((x) => x.ingestConnectionState !== null); 33 - const selectedRendition = usePlayerStore((x) => x.selectedRendition); 34 - const src = usePlayerStore((x) => x.src); 35 - const { url, protocol } = srcToUrl({ src: src, selectedRendition }, inProto); 36 - if (isIngesting) { 37 - return <WebcamIngestPlayer url={url} />; 38 - } 39 - if (protocol === PlayerProtocol.PROGRESSIVE_MP4) { 40 - return <ProgressiveMP4Player url={url} />; 41 - } else if (protocol === PlayerProtocol.PROGRESSIVE_WEBM) { 42 - return <ProgressiveWebMPlayer url={url} />; 43 - } else if (protocol === PlayerProtocol.HLS) { 44 - return <HLSPlayer url={url} />; 45 - } else if (protocol === PlayerProtocol.WEBRTC) { 46 - return <WebRTCPlayer url={url} />; 47 - } else { 48 - throw new Error(`unknown playback protocol ${inProto}`); 49 - } 50 - } 51 - 52 - const updateEvents = { 53 - playing: true, 54 - waiting: true, 55 - stalled: true, 56 - pause: true, 57 - suspend: true, 58 - mute: true, 59 - }; 60 - 61 - const VideoElement = forwardRef( 62 - (props: VideoProps, refCallback: ForwardedRef<HTMLVideoElement | null>) => { 63 - const x = usePlayerStore((x) => x); 64 - const url = useStreamplaceStore((x) => x.url); 65 - const playerEvent = usePlayerStore((x) => x.playerEvent); 66 - const setMuted = usePlayerStore((x) => x.setMuted); 67 - const setMuteWasForced = usePlayerStore((x) => x.setMuteWasForced); 68 - const muted = usePlayerStore((x) => x.muted); 69 - const ingest = usePlayerStore((x) => x.ingestConnectionState !== null); 70 - const volume = usePlayerStore((x) => x.volume); 71 - const setStatus = usePlayerStore((x) => x.setStatus); 72 - const setUserInteraction = usePlayerStore((x) => x.setUserInteraction); 73 - 74 - const event = (evType) => (e) => { 75 - console.log(evType); 76 - const now = new Date(); 77 - if (updateEvents[evType]) { 78 - x.setStatus(evType); 79 - } 80 - console.log("Sending", evType, "status to", url); 81 - playerEvent(url, now.toISOString(), evType, {}); 82 - }; 83 - const [firstAttempt, setFirstAttempt] = useState(true); 84 - 85 - const localVideoRef = useRef<HTMLVideoElement | null>(null); 86 - 87 - // setPipAction comes from Zustand store 88 - useEffect(() => { 89 - if (typeof x.setPipAction === "function") { 90 - const fn = () => { 91 - if (localVideoRef.current) { 92 - try { 93 - localVideoRef.current.requestPictureInPicture?.(); 94 - } catch (err) { 95 - console.error("Error requesting Picture-in-Picture:", err); 96 - } 97 - } else { 98 - console.log("No video ref available for PiP"); 99 - } 100 - }; 101 - x.setPipAction(fn); 102 - } 103 - // Cleanup on unmount 104 - return () => { 105 - if (typeof x.setPipAction === "function") { 106 - x.setPipAction(undefined); 107 - } 108 - }; 109 - }, []); 110 - 111 - // Memoized callback ref for video element 112 - const handleVideoRef = useCallback( 113 - (videoElement: HTMLVideoElement | null) => { 114 - localVideoRef.current = videoElement; 115 - if (typeof refCallback === "function") { 116 - refCallback(videoElement); 117 - } else if (refCallback && "current" in refCallback) { 118 - refCallback.current = videoElement; 119 - } 120 - }, 121 - [refCallback], 122 - ); 123 - 124 - // attempts to autoplay the video. if that fails, it attempts 125 - // to play the video muted; some browsers will only let you 126 - // autoplay if you're muted 127 - const canPlayThrough = (e) => { 128 - event("canplaythrough")(e); 129 - if (firstAttempt && localVideoRef.current) { 130 - setFirstAttempt(false); 131 - localVideoRef.current.play().catch((err) => { 132 - if (err.name === "NotAllowedError") { 133 - if (localVideoRef.current) { 134 - setMuted(true); 135 - localVideoRef.current.muted = true; 136 - localVideoRef.current 137 - .play() 138 - .then(() => { 139 - console.warn("Browser forced video to start muted"); 140 - setMuteWasForced(true); 141 - }) 142 - .catch((err) => { 143 - console.error("error playing video", err); 144 - }); 145 - } 146 - } 147 - }); 148 - } 149 - }; 150 - 151 - useEffect(() => { 152 - return () => { 153 - setStatus(PlayerStatus.START); 154 - }; 155 - }, [setStatus]); 156 - 157 - useEffect(() => { 158 - if (localVideoRef.current) { 159 - localVideoRef.current.volume = volume; 160 - console.log("Setting volume to", volume); 161 - } 162 - }, [volume]); 163 - 164 - return ( 165 - <View 166 - backgroundColor="#111" 167 - alignItems="stretch" 168 - f={1} 169 - onPointerMove={setUserInteraction} 170 - > 171 - <video 172 - autoPlay={true} 173 - playsInline={true} 174 - ref={handleVideoRef} 175 - controls={false} 176 - src={ingest ? undefined : props.url} 177 - muted={muted} 178 - crossOrigin="anonymous" 179 - onMouseMove={setUserInteraction} 180 - onClick={setUserInteraction} 181 - onAbort={event("abort")} 182 - onCanPlay={event("canplay")} 183 - onCanPlayThrough={canPlayThrough} 184 - // onDurationChange={event("durationchange")} 185 - onEmptied={event("emptied")} 186 - onEncrypted={event("encrypted")} 187 - onEnded={event("ended")} 188 - onError={event("error")} 189 - onLoadedData={event("loadeddata")} 190 - onLoadedMetadata={event("loadedmetadata")} 191 - onLoadStart={event("loadstart")} 192 - onPause={event("pause")} 193 - onPlay={event("play")} 194 - onPlaying={event("playing")} 195 - // onProgress={event("progress")} 196 - // onTimeUpdate={event("timeupdate")} 197 - onRateChange={event("ratechange")} 198 - onSeeked={event("seeked")} 199 - onSeeking={event("seeking")} 200 - onStalled={event("stalled")} 201 - onSuspend={event("suspend")} 202 - onVolumeChange={event("volumechange")} 203 - onWaiting={event("waiting")} 204 - style={{ 205 - objectFit: "contain", 206 - backgroundColor: "transparent", 207 - width: "100%", 208 - height: "100%", 209 - transform: ingest ? "scaleX(-1)" : undefined, 210 - }} 211 - /> 212 - </View> 213 - ); 214 - }, 215 - ); 216 - 217 - export function ProgressiveMP4Player(props: VideoProps) { 218 - return <VideoElement {...props} />; 219 - } 220 - 221 - export function ProgressiveWebMPlayer(props: VideoProps) { 222 - return <VideoElement {...props} />; 223 - } 224 - 225 - export function HLSPlayer(props: VideoProps) { 226 - const localRef = useRef<HTMLVideoElement | null>(null); 227 - 228 - const videoRef = usePlayerStore((x) => x.videoRef); 229 - const setVideoRef = usePlayerStore((x) => x.setVideoRef); 230 - 231 - const refCallback = useCallback((node: HTMLVideoElement | null) => { 232 - localRef.current = node; 233 - if (typeof videoRef === "function") { 234 - videoRef(node); 235 - } else if (videoRef) { 236 - localRef.current = node; 237 - setVideoRef(localRef); 238 - } 239 - }, []); 240 - useEffect(() => { 241 - if (!localRef.current) { 242 - return; 243 - } 244 - if (Hls.isSupported()) { 245 - // workaround for not having quite the right number of audio frames :( 246 - var hls = new Hls({ maxAudioFramesDrift: 20 }); 247 - hls.loadSource(props.url); 248 - try { 249 - hls.attachMedia(localRef.current); 250 - } catch (e) { 251 - console.error("error on attachMedia"); 252 - hls.stopLoad(); 253 - return; 254 - } 255 - hls.on(Hls.Events.MANIFEST_PARSED, () => { 256 - if (!localRef.current) { 257 - return; 258 - } 259 - localRef.current.play(); 260 - }); 261 - return () => { 262 - hls.stopLoad(); 263 - }; 264 - } else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) { 265 - localRef.current.src = props.url; 266 - localRef.current.addEventListener("canplay", () => { 267 - if (!localRef.current) { 268 - return; 269 - } 270 - localRef.current.play(); 271 - }); 272 - } 273 - }, [props.url]); 274 - return <VideoElement {...props} ref={refCallback} />; 275 - } 276 - 277 - export function WebRTCPlayer(props: VideoProps) { 278 - const [webrtcError, setWebrtcError] = useState<string | null>(null); 279 - const setStatus = usePlayerStore((x) => x.setStatus); 280 - const setProtocol = usePlayerStore((x) => x.setProtocol); 281 - const diagnostics = useWebRTCDiagnostics(); 282 - // Check WebRTC compatibility on component mount 283 - useEffect(() => { 284 - try { 285 - checkWebRTCSupport(); 286 - console.log("WebRTC Player - Browser compatibility check passed"); 287 - logWebRTCDiagnostics(); 288 - } catch (error) { 289 - console.error("WebRTC Player - Compatibility error:", error.message); 290 - setWebrtcError(error.message); 291 - setStatus(PlayerStatus.START); 292 - return; 293 - } 294 - }, []); 295 - 296 - // Monitor diagnostics for errors 297 - useEffect(() => { 298 - if (!diagnostics.browserSupport && diagnostics.errors.length > 0) { 299 - setWebrtcError(diagnostics.errors.join(", ")); 300 - } 301 - }, [diagnostics]); 302 - 303 - if (!diagnostics.done) return <></>; 304 - 305 - if (webrtcError) { 306 - setProtocol(PlayerProtocol.HLS); 307 - return ( 308 - <View 309 - backgroundColor="#111" 310 - alignItems="center" 311 - justifyContent="center" 312 - f={1} 313 - padding="$4" 314 - > 315 - <View 316 - backgroundColor="$red10" 317 - padding="$3" 318 - borderRadius="$4" 319 - maxWidth={400} 320 - > 321 - <View marginBottom="$2"> 322 - <Text fontSize="$8" fontWeight="bold" color="white"> 323 - WebRTC Not Supported 324 - </Text> 325 - </View> 326 - <Text fontSize="$4" color="white" lineHeight="$1" marginBottom="$3"> 327 - {webrtcError} 328 - </Text> 329 - {diagnostics.errors.length > 0 && ( 330 - <View> 331 - <Text 332 - fontSize="$4" 333 - fontWeight="bold" 334 - color="white" 335 - marginBottom="$2" 336 - > 337 - Technical Details: 338 - </Text> 339 - {diagnostics.errors.map((error, index) => ( 340 - <Text key={index} fontSize="$3" color="white" marginBottom="$1"> 341 - • {error} 342 - </Text> 343 - ))} 344 - </View> 345 - )} 346 - <Text fontSize="$3"> 347 - • To use WebRTC, you may need to disable any blocking extensions or 348 - update your browser. 349 - </Text> 350 - <Text mt="$2">Switching to HLS...</Text> 351 - </View> 352 - </View> 353 - ); 354 - } 355 - return <WebRTCPlayerInner url={props.url} />; 356 - } 357 - 358 - export function WebRTCPlayerInner({ url }: { url: string }) { 359 - const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 360 - null, 361 - ); 362 - const [connectionStatus, setConnectionStatus] = 363 - useState<string>("initializing"); 364 - 365 - const localVideoRef = useRef<HTMLVideoElement | null>(null); 366 - 367 - const videoRef = usePlayerStore((x) => x.videoRef); 368 - const setVideoRef = usePlayerStore((x) => x.setVideoRef); 369 - 370 - const status = usePlayerStore((x) => x.status); 371 - const setStatus = usePlayerStore((x) => x.setStatus); 372 - 373 - const playerEvent = usePlayerStore((x) => x.playerEvent); 374 - const spurl = useStreamplaceStore((x) => x.url); 375 - 376 - const handleRef = useCallback((node: HTMLVideoElement | null) => { 377 - if (node) { 378 - setVideoElement(node); 379 - } 380 - if (typeof videoRef === "function") { 381 - videoRef(node); 382 - } else if (setVideoRef) { 383 - setVideoRef(localVideoRef); 384 - } 385 - }, []); 386 - 387 - const [mediaStream, stuck] = useWebRTC(url); 388 - 389 - // Debug logging for WebRTC connection state 390 - useEffect(() => { 391 - // Update connection status based on state 392 - if (stuck) { 393 - setConnectionStatus("connection-failed"); 394 - } else if (mediaStream) { 395 - setConnectionStatus("connected"); 396 - } else { 397 - setConnectionStatus("connecting"); 398 - } 399 - }, [url, mediaStream, stuck, status]); 400 - 401 - useEffect(() => { 402 - if (stuck && status === PlayerStatus.PLAYING) { 403 - setStatus(PlayerStatus.STALLED); 404 - } 405 - if (!stuck && mediaStream) { 406 - setStatus(PlayerStatus.PLAYING); 407 - } 408 - }, [stuck, status, mediaStream]); 409 - 410 - useEffect(() => { 411 - if (!mediaStream) { 412 - return; 413 - } 414 - const evt = (evType) => (e) => { 415 - console.log("webrtc event", evType); 416 - playerEvent(spurl, new Date().toISOString(), evType, {}); 417 - }; 418 - const active = evt("active"); 419 - const inactive = evt("inactive"); 420 - const ended = evt("ended"); 421 - const mute = evt("mute"); 422 - const unmute = evt("playing"); // playing has resumed yay 423 - 424 - mediaStream.addEventListener("active", active); 425 - mediaStream.addEventListener("inactive", inactive); 426 - mediaStream.addEventListener("ended", ended); 427 - for (const track of mediaStream.getTracks()) { 428 - track.addEventListener("ended", ended); 429 - track.addEventListener("mute", mute); 430 - track.addEventListener("unmute", unmute); 431 - } 432 - return () => { 433 - for (const track of mediaStream.getTracks()) { 434 - track.removeEventListener("ended", ended); 435 - track.removeEventListener("mute", mute); 436 - track.removeEventListener("unmute", unmute); 437 - } 438 - mediaStream.removeEventListener("active", active); 439 - mediaStream.removeEventListener("inactive", inactive); 440 - mediaStream.removeEventListener("ended", ended); 441 - }; 442 - }, [mediaStream]); 443 - 444 - // Test not working right now 445 - // useEffect(() => { 446 - // if (!props.avSyncTest) { 447 - // return; 448 - // } 449 - // if (!mediaStream) { 450 - // return; 451 - // } 452 - // quietReceiver(mediaStream, playerEvent); 453 - // }, [mediaStream, props.avSyncTest, playerEvent]); 454 - 455 - useEffect(() => { 456 - if (!videoElement) { 457 - return; 458 - } 459 - videoElement.srcObject = mediaStream; 460 - }, [videoElement, mediaStream]); 461 - 462 - // Show loading/connection status when no media stream is available 463 - if (!mediaStream) { 464 - return ( 465 - <View 466 - backgroundColor="#111" 467 - alignItems="center" 468 - justifyContent="center" 469 - f={1} 470 - padding="$4" 471 - > 472 - <View 473 - backgroundColor="$blue10" 474 - padding="$3" 475 - borderRadius="$4" 476 - maxWidth={400} 477 - > 478 - <View marginBottom="$2"> 479 - <Text fontSize="$6" fontWeight="bold" color="white"> 480 - Connecting... 481 - </Text> 482 - </View> 483 - <Text fontSize="$4" color="white" lineHeight="$1"> 484 - Establishing WebRTC connection ({connectionStatus}) 485 - </Text> 486 - </View> 487 - </View> 488 - ); 489 - } 490 - return <VideoElement url={url} ref={handleRef} />; 491 - } 492 - 493 - export function WebcamIngestPlayer(props: VideoProps) { 494 - const ingestStarting = usePlayerStore((x) => x.ingestStarting); 495 - const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); 496 - const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); 497 - 498 - const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 499 - null, 500 - ); 501 - const handleRef = useCallback((node: HTMLVideoElement | null) => { 502 - if (node) { 503 - setVideoElement(node); 504 - } 505 - }, []); 506 - 507 - const { url } = useStreamplaceNode(); 508 - const [localMediaStream, setLocalMediaStream] = useState<MediaStream | null>( 509 - null, 510 - ); 511 - // we assign a stream key in the webrtcingest hook 512 - const [remoteMediaStream, setRemoteMediaStream] = useWebRTCIngest({ 513 - endpoint: `${url}/api/ingest/webrtc`, 514 - }); 515 - 516 - useEffect(() => { 517 - if (ingestMediaSource === IngestMediaSource.DISPLAY) { 518 - navigator.mediaDevices 519 - .getDisplayMedia({ 520 - audio: true, 521 - video: true, 522 - }) 523 - .then((stream) => { 524 - setLocalMediaStream(stream); 525 - }) 526 - .catch((e) => { 527 - console.error("error getting display media", e); 528 - }); 529 - } else { 530 - navigator.mediaDevices 531 - .getUserMedia({ 532 - audio: true, 533 - video: { 534 - width: { min: 200, ideal: 1920, max: 3840 }, 535 - height: { min: 200, ideal: 1080, max: 2160 }, 536 - }, 537 - }) 538 - .then((stream) => { 539 - setLocalMediaStream(stream); 540 - }) 541 - .catch((e) => { 542 - console.error("error getting user media", e); 543 - }); 544 - } 545 - }, [ingestMediaSource]); 546 - 547 - useEffect(() => { 548 - if (!ingestStarting && !ingestAutoStart) { 549 - setRemoteMediaStream(null); 550 - return; 551 - } 552 - if (!localMediaStream) { 553 - return; 554 - } 555 - if (!streamKey) { 556 - return; 557 - } 558 - setRemoteMediaStream(localMediaStream); 559 - }, [localMediaStream, ingestStarting, streamKey, ingestAutoStart]); 560 - 561 - useEffect(() => { 562 - if (!videoElement) { 563 - return; 564 - } 565 - if (!localMediaStream) { 566 - return; 567 - } 568 - videoElement.srcObject = localMediaStream; 569 - }, [videoElement, localMediaStream]); 570 - 571 - return <VideoElement {...props} ref={handleRef} />; 572 - }
-145
js/app/components/player/webrtc-diagnostics.tsx
··· 1 - import { useEffect, useState } from "react"; 2 - 3 - export interface WebRTCDiagnostics { 4 - done: boolean; 5 - browserSupport: boolean; 6 - rtcPeerConnection: boolean; 7 - rtcSessionDescription: boolean; 8 - getUserMedia: boolean; 9 - getDisplayMedia: boolean; 10 - errors: string[]; 11 - warnings: string[]; 12 - } 13 - 14 - export function useWebRTCDiagnostics(): WebRTCDiagnostics { 15 - const [diagnostics, setDiagnostics] = useState<WebRTCDiagnostics>({ 16 - done: false, 17 - browserSupport: false, 18 - rtcPeerConnection: false, 19 - rtcSessionDescription: false, 20 - getUserMedia: false, 21 - getDisplayMedia: false, 22 - errors: [], 23 - warnings: [], 24 - }); 25 - 26 - useEffect(() => { 27 - const errors: string[] = []; 28 - const warnings: string[] = []; 29 - 30 - // Check if we're in a browser environment 31 - if (typeof window === "undefined") { 32 - errors.push("Running in non-browser environment"); 33 - setDiagnostics({ 34 - done: false, 35 - browserSupport: false, 36 - rtcPeerConnection: false, 37 - rtcSessionDescription: false, 38 - getUserMedia: false, 39 - getDisplayMedia: false, 40 - errors, 41 - warnings, 42 - }); 43 - return; 44 - } 45 - 46 - // Check RTCPeerConnection support 47 - const rtcPeerConnection = !!( 48 - window.RTCPeerConnection || 49 - (window as any).webkitRTCPeerConnection || 50 - (window as any).mozRTCPeerConnection 51 - ); 52 - 53 - if (!rtcPeerConnection) { 54 - errors.push("RTCPeerConnection is not supported"); 55 - } 56 - 57 - // Check RTCSessionDescription support 58 - const rtcSessionDescription = !!( 59 - window.RTCSessionDescription || 60 - (window as any).webkitRTCSessionDescription || 61 - (window as any).mozRTCSessionDescription 62 - ); 63 - 64 - if (!rtcSessionDescription) { 65 - errors.push("RTCSessionDescription is not supported"); 66 - } 67 - 68 - // Check getUserMedia support 69 - const getUserMedia = !!( 70 - navigator.mediaDevices?.getUserMedia || 71 - (navigator as any).getUserMedia || 72 - (navigator as any).webkitGetUserMedia || 73 - (navigator as any).mozGetUserMedia 74 - ); 75 - 76 - if (!getUserMedia) { 77 - warnings.push( 78 - "getUserMedia is not supported - webcam features unavailable", 79 - ); 80 - } 81 - 82 - // Check getDisplayMedia support 83 - const getDisplayMedia = !!navigator.mediaDevices?.getDisplayMedia; 84 - 85 - if (!getDisplayMedia) { 86 - warnings.push( 87 - "getDisplayMedia is not supported - screen sharing unavailable", 88 - ); 89 - } 90 - 91 - // Check if running over HTTPS (required for some WebRTC features) 92 - if (location.protocol !== "https:" && location.hostname !== "localhost") { 93 - warnings.push("WebRTC features may be limited over HTTP connections"); 94 - } 95 - 96 - // Check browser-specific issues 97 - const userAgent = navigator.userAgent.toLowerCase(); 98 - if (userAgent.includes("safari") && !userAgent.includes("chrome")) { 99 - warnings.push("Safari may have limited WebRTC codec support"); 100 - } 101 - 102 - const browserSupport = rtcPeerConnection && rtcSessionDescription; 103 - 104 - setDiagnostics({ 105 - done: true, 106 - browserSupport, 107 - rtcPeerConnection, 108 - rtcSessionDescription, 109 - getUserMedia, 110 - getDisplayMedia, 111 - errors, 112 - warnings, 113 - }); 114 - }, []); 115 - 116 - return diagnostics; 117 - } 118 - 119 - export function logWebRTCDiagnostics() { 120 - console.group("WebRTC Diagnostics"); 121 - 122 - // Log browser support 123 - console.log("RTCPeerConnection:", !!window.RTCPeerConnection); 124 - console.log("RTCSessionDescription:", !!window.RTCSessionDescription); 125 - console.log("getUserMedia:", !!navigator.mediaDevices?.getUserMedia); 126 - console.log("getDisplayMedia:", !!navigator.mediaDevices?.getDisplayMedia); 127 - 128 - // Log browser info 129 - console.log("User Agent:", navigator.userAgent); 130 - console.log("Protocol:", location.protocol); 131 - console.log("Host:", location.hostname); 132 - 133 - // Test basic WebRTC functionality 134 - if (window.RTCPeerConnection) { 135 - try { 136 - const pc = new RTCPeerConnection(); 137 - console.log("RTCPeerConnection creation: ✓ Success"); 138 - pc.close(); 139 - } catch (error) { 140 - console.error("RTCPeerConnection creation: ✗ Failed", error); 141 - } 142 - } 143 - 144 - console.groupEnd(); 145 - }
-6
js/app/components/player/webrtc-primitives.native.tsx
··· 1 - export { 2 - RTCPeerConnection, 3 - RTCSessionDescription, 4 - MediaStream as WebRTCMediaStream, 5 - mediaDevices, 6 - } from "react-native-webrtc";
-33
js/app/components/player/webrtc-primitives.tsx
··· 1 - // Browser compatibility checks for WebRTC 2 - const checkWebRTCSupport = () => { 3 - if (typeof window === "undefined") { 4 - throw new Error("WebRTC is not available in non-browser environments"); 5 - } 6 - 7 - if (!window.RTCPeerConnection) { 8 - throw new Error( 9 - "RTCPeerConnection is not supported in this browser. Please use a modern browser that supports WebRTC.", 10 - ); 11 - } 12 - 13 - if (!window.RTCSessionDescription) { 14 - throw new Error( 15 - "RTCSessionDescription is not supported in this browser. Please use a modern browser that supports WebRTC.", 16 - ); 17 - } 18 - }; 19 - 20 - // Check support immediately 21 - try { 22 - checkWebRTCSupport(); 23 - } catch (error) { 24 - console.error("WebRTC Compatibility Error:", error.message); 25 - } 26 - 27 - export const RTCPeerConnection = window.RTCPeerConnection; 28 - export const RTCSessionDescription = window.RTCSessionDescription; 29 - export const WebRTCMediaStream = window.MediaStream; 30 - export const mediaDevices = navigator.mediaDevices; 31 - 32 - // Export the compatibility checker for use in other components 33 - export { checkWebRTCSupport };
-20
js/app/src/router.tsx
··· 64 64 import { H3, Text, useTheme, View } from "tamagui"; 65 65 import AboutScreen from "./screens/about"; 66 66 import AppReturnScreen from "./screens/app-return"; 67 - import AVSyncScreen from "./screens/av-sync"; 68 67 import PopoutChat from "./screens/chat-popout"; 69 68 import DownloadScreen from "./screens/download"; 70 69 import EmbedScreen from "./screens/embed"; 71 70 import InfoWidgetEmbed from "./screens/info-widget-embed"; 72 71 import LiveDashboard from "./screens/live-dashboard"; 73 72 import MultiScreen from "./screens/multi"; 74 - import StreamScreen from "./screens/stream"; 75 73 import SupportScreen from "./screens/support"; 76 74 77 75 // probabl should move this ··· 514 512 }} 515 513 /> 516 514 <Drawer.Screen 517 - name="AVSync" 518 - component={AVSyncScreen} 519 - options={{ 520 - drawerLabel: () => null, 521 - drawerItemStyle: { display: "none" }, 522 - headerShown: false, 523 - }} 524 - /> 525 - <Drawer.Screen 526 515 name="Login" 527 516 component={Login} 528 517 options={{ ··· 565 554 drawerLabel: () => null, 566 555 drawerItemStyle: { display: "none" }, 567 556 headerShown: false, 568 - }} 569 - /> 570 - <Drawer.Screen 571 - name="LegacyStream" 572 - component={StreamScreen} 573 - options={{ 574 - headerTitle: "Stream", 575 - drawerItemStyle: { display: "none" }, 576 - title: "Streamplace Stream", 577 557 }} 578 558 /> 579 559 <Drawer.Screen
-5
js/app/src/screens/av-sync.native.tsx
··· 1 - import { View } from "tamagui"; 2 - 3 - export default function AVSync() { 4 - return <View></View>; 5 - }
-66
js/app/src/screens/av-sync.tsx
··· 1 - import { Countdown } from "components"; 2 - import { QUIET_PROFILE } from "components/player/av-sync"; 3 - import QRCode from "qrcode"; 4 - import { str2ab } from "quietjs-bundle"; 5 - import { useEffect, useRef } from "react"; 6 - import { View } from "tamagui"; 7 - 8 - // screen that displays timestamp as a QR code and encodes timestamp in audio 9 - // so we can measure sync between them 10 - 11 - export default function AVSyncScreen() { 12 - useEffect(() => { 13 - let interval: NodeJS.Timeout | null = null; 14 - async function initQuiet() { 15 - const quiet = await import("quietjs-bundle"); 16 - quiet.addReadyCallback(() => { 17 - const transmitter = quiet.transmitter({ 18 - profile: QUIET_PROFILE, 19 - }); 20 - interval = setInterval(() => { 21 - transmitter.transmit(str2ab(`${Date.now()}`)); 22 - }, 500); 23 - }); 24 - } 25 - initQuiet(); 26 - return () => { 27 - if (interval) { 28 - clearInterval(interval); 29 - } 30 - }; 31 - }, []); 32 - 33 - const canvasRef = useRef<HTMLCanvasElement>(null); 34 - useEffect(() => { 35 - let stopped = false; 36 - const frame = () => { 37 - if (stopped) { 38 - return; 39 - } 40 - if (canvasRef.current) { 41 - QRCode.toCanvas(canvasRef.current, `${Date.now()}`, function (error) { 42 - if (error) console.error(error); 43 - }); 44 - } 45 - requestAnimationFrame(frame); 46 - }; 47 - frame(); 48 - return () => { 49 - stopped = true; 50 - }; 51 - }, []); 52 - 53 - return ( 54 - <View flex={1} justifyContent="center" alignItems="center"> 55 - <View f={1} justifyContent="center" alignItems="center"> 56 - <Countdown from="now" /> 57 - </View> 58 - <View height={348} f={1} justifyContent="center" alignItems="center"> 59 - <canvas 60 - ref={canvasRef} 61 - style={{ transform: "scale(3)", imageRendering: "pixelated" }} 62 - /> 63 - </View> 64 - </View> 65 - ); 66 - }
-24
js/app/src/screens/stream.tsx
··· 1 - import Livestream from "components/livestream/livestream"; 2 - import { PlayerProps } from "components/player/props"; 3 - import { FullscreenProvider } from "contexts/FullscreenContext"; 4 - import useTitle from "hooks/useTitle"; 5 - import { isWeb } from "tamagui"; 6 - import { queryToProps } from "./util"; 7 - 8 - export default function StreamScreen({ route }) { 9 - const { user, protocol, url } = route.params; 10 - let extraProps: Partial<PlayerProps> = {}; 11 - if (isWeb) { 12 - extraProps = queryToProps(new URLSearchParams(window.location.search)); 13 - } 14 - let src = user; 15 - if (user === "stream") { 16 - src = url; 17 - } 18 - useTitle(user); 19 - return ( 20 - <FullscreenProvider> 21 - <Livestream src={src} {...extraProps} /> 22 - </FullscreenProvider> 23 - ); 24 - }