Live video on the AT Protocol

Merge pull request #887 from streamplace/natb/live-stop

feat: add a "stop stream" button

authored by

Eli Mallon and committed by
GitHub
20bcf6e0 ca0d59bd

+229 -13
+4
js/app/components/login/login-modal.tsx
··· 16 16 }: LoginModalProps) { 17 17 const { theme, zero: z } = useTheme(); 18 18 19 + if (!visible) { 20 + return null; 21 + } 22 + 19 23 return ( 20 24 <Modal 21 25 visible={visible}
+1
js/app/components/mobile/desktop-ui.tsx
··· 254 254 setTitle={setTitle} 255 255 ingestStarting={ingestStarting} 256 256 toggleGoLive={toggleGoLive} 257 + isLive={isActivelyLive} 257 258 /> 258 259 )} 259 260
+16 -3
js/app/components/mobile/ui.tsx
··· 72 72 ingestStarting, 73 73 setIngestStarting, 74 74 toggleGoLive, 75 + toggleStopStream, 75 76 } = useLivestreamInfo(); 76 77 const { width, height } = usePlayerDimensions(); 77 78 const { isPlayerRatioGreater } = useSegmentDimensions(); ··· 102 103 103 104 const isSelfAndNotLive = ingest === "new"; 104 105 const isLive = ingest !== null && ingest !== "new"; 106 + 107 + useEffect(() => { 108 + if (isLive && ingestStarting) { 109 + setIngestStarting(false); 110 + } 111 + }, [isLive, ingestStarting, setIngestStarting]); 105 112 106 113 const FADE_OUT_DELAY = 4000; 107 114 const fadeOpacity = useSharedValue(1); ··· 222 229 <View 223 230 style={[ 224 231 layout.position.absolute, 225 - position.top[28], 232 + position.top[32], 226 233 position.left[0], 227 234 position.right[0], 228 235 layout.flex.column, ··· 230 237 ]} 231 238 > 232 239 <PlayerUI.MetricsPanel 233 - showMetrics={isLive || isSelfAndNotLive} 240 + showMetrics={shouldShowFloatingMetrics} 234 241 /> 235 242 </View> 236 243 )} ··· 241 248 setTitle={setTitle} 242 249 ingestStarting={ingestStarting} 243 250 toggleGoLive={toggleGoLive} 251 + isLive={isLive} 244 252 /> 245 253 )} 246 254 ··· 468 476 <Pressable onPress={doSetIngestCamera}> 469 477 <SwitchCamera color={theme.colors.foreground} size={20} /> 470 478 </Pressable> 479 + {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} 471 480 </> 472 481 )} 473 482 {Platform.OS === "web" ? ( ··· 515 524 )} 516 525 </Pressable> 517 526 )} 518 - <PlayerUI.ContextMenu /> 527 + {ingest === null ? ( 528 + <PlayerUI.ContextMenu /> 529 + ) : ( 530 + <PlayerUI.StreamContextMenu /> 531 + )} 519 532 </View> 520 533 )} 521 534 {shouldShowChatSidePanel && setShowChat && (
+42 -8
js/components/src/components/mobile-player/ui/input.tsx
··· 9 9 setTitle: (title: string) => void; 10 10 ingestStarting: boolean; 11 11 toggleGoLive: () => void; 12 + isLive: boolean; 13 + toggleStopStream?: () => void; 12 14 }; 13 15 14 16 export function InputPanel({ ··· 16 18 setTitle, 17 19 ingestStarting, 18 20 toggleGoLive, 21 + isLive, 22 + toggleStopStream, 19 23 }: InputPanelProps) { 20 24 const { slideKeyboard } = useKeyboardSlide(); 21 25 return ( ··· 37 41 { padding: 10 }, 38 42 ]} 39 43 > 40 - <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 - <Input 42 - value={title} 43 - onChange={setTitle} 44 - placeholder="Enter stream title" 45 - onEndEditing={Keyboard.dismiss} 46 - /> 47 - </View> 44 + {!isLive && ( 45 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 46 + <Input 47 + value={title} 48 + onChange={setTitle} 49 + placeholder="Enter stream title" 50 + onEndEditing={Keyboard.dismiss} 51 + /> 52 + </View> 53 + )} 48 54 {ingestStarting ? ( 49 55 <Text>Starting your stream...</Text> 56 + ) : isLive ? ( 57 + <View style={[layout.flex.center]}> 58 + <Pressable 59 + onPress={toggleStopStream} 60 + style={[ 61 + px[4], 62 + py[2], 63 + layout.flex.row, 64 + layout.flex.center, 65 + gap.all[1], 66 + { 67 + backgroundColor: "rgba(64,64,64, 0.8)", 68 + borderRadius: 12, 69 + }, 70 + ]} 71 + > 72 + <View 73 + style={[ 74 + p[2], 75 + { 76 + backgroundColor: "rgba(256,0,0, 0.8)", 77 + borderRadius: 12, 78 + }, 79 + ]} 80 + /> 81 + <Text center>Stop Stream</Text> 82 + </Pressable> 83 + </View> 50 84 ) : ( 51 85 <View style={[layout.flex.center]}> 52 86 <Pressable
+138 -2
js/components/src/components/mobile-player/ui/streamer-context-menu.tsx
··· 1 - export function StreamContextMenu() { 2 - return <></>; 1 + import { ChevronRight, Cog } from "lucide-react-native"; 2 + import { useEffect, useState } from "react"; 3 + import Animated, { 4 + Easing, 5 + useAnimatedStyle, 6 + useSharedValue, 7 + withDelay, 8 + withSequence, 9 + withTiming, 10 + } from "react-native-reanimated"; 11 + import { useLivestreamInfo, zero } from "../../.."; 12 + import { usePlayerStore } from "../../../player-store"; 13 + import { 14 + DropdownMenu, 15 + DropdownMenuCheckboxItem, 16 + DropdownMenuGroup, 17 + DropdownMenuItem, 18 + DropdownMenuTrigger, 19 + ResponsiveDropdownMenuContent, 20 + Text, 21 + useTheme, 22 + } from "../../ui"; 23 + 24 + export function StreamContextMenu({ 25 + dropdownPortalContainer, 26 + }: { 27 + dropdownPortalContainer?: string; 28 + }) { 29 + const th = useTheme(); 30 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 31 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 32 + const { toggleStopStream } = useLivestreamInfo(); 33 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 34 + const isLive = ingest !== null && ingest !== "new"; 35 + 36 + const [isOpen, setIsOpen] = useState(false); 37 + const [hasShownTooltip, setHasShownTooltip] = useState(false); 38 + 39 + const tooltipOpacity = useSharedValue(0); 40 + const tooltipTranslateX = useSharedValue(20); 41 + 42 + useEffect(() => { 43 + if (isLive && !hasShownTooltip) { 44 + tooltipOpacity.value = withDelay( 45 + 500, 46 + withSequence( 47 + withTiming(1, { duration: 300 }), 48 + withDelay(10000, withTiming(0, { duration: 300 })), 49 + ), 50 + ); 51 + tooltipTranslateX.value = withDelay( 52 + 500, 53 + withSequence( 54 + withTiming(0, { duration: 300 }), 55 + withDelay(10000, withTiming(20, { duration: 300 })), 56 + ), 57 + ); 58 + setHasShownTooltip(true); 59 + } 60 + }, [isLive, hasShownTooltip]); 61 + 62 + const iconRotate = useAnimatedStyle(() => { 63 + return { 64 + transform: [ 65 + { 66 + rotateZ: withTiming(isOpen ? "240deg" : "0deg", { 67 + duration: 650, 68 + easing: Easing.out(Easing.ease), 69 + }), 70 + }, 71 + ], 72 + }; 73 + }); 74 + 75 + const tooltipStyle = useAnimatedStyle(() => { 76 + return { 77 + opacity: tooltipOpacity.value, 78 + transform: [{ translateX: tooltipTranslateX.value }], 79 + }; 80 + }); 81 + 82 + return ( 83 + <DropdownMenu onOpenChange={setIsOpen} key={dropdownPortalContainer}> 84 + <DropdownMenuTrigger> 85 + <Animated.View style={[iconRotate]}> 86 + <Cog color={th.theme.colors.foreground} /> 87 + </Animated.View> 88 + <Animated.View 89 + style={[ 90 + tooltipStyle, 91 + { 92 + position: "absolute", 93 + right: 30, 94 + top: 0, 95 + backgroundColor: "rgba(64,64,64,0.95)", 96 + borderRadius: 8, 97 + paddingHorizontal: 8, 98 + paddingRight: 12, 99 + paddingVertical: 4, 100 + flexDirection: "row", 101 + alignItems: "center", 102 + gap: 6, 103 + zIndex: 9999999, 104 + pointerEvents: "box-none", 105 + width: 120, 106 + }, 107 + ]} 108 + > 109 + <Text size="sm" color="white"> 110 + End stream here 111 + </Text> 112 + <ChevronRight color="white" size={16} style={[zero.mr[4]]} /> 113 + </Animated.View> 114 + </DropdownMenuTrigger> 115 + <ResponsiveDropdownMenuContent side="top" align="end"> 116 + {isLive && ( 117 + <DropdownMenuGroup title="Stream"> 118 + <DropdownMenuItem 119 + closeOnPress={true} 120 + onPress={() => { 121 + toggleStopStream(); 122 + }} 123 + > 124 + <Text color="destructive">Stop Stream</Text> 125 + </DropdownMenuItem> 126 + </DropdownMenuGroup> 127 + )} 128 + <DropdownMenuGroup title="Advanced"> 129 + <DropdownMenuCheckboxItem 130 + checked={debugInfo} 131 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 132 + > 133 + <Text>Show Debug Info</Text> 134 + </DropdownMenuCheckboxItem> 135 + </DropdownMenuGroup> 136 + </ResponsiveDropdownMenuContent> 137 + </DropdownMenu> 138 + ); 3 139 }
+8
js/components/src/hooks/useLivestreamInfo.ts
··· 9 9 const ingestStarting = usePlayerStore((x) => x.ingestStarting); 10 10 const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 11 11 const setIngestLive = usePlayerStore((x) => x.setIngestLive); 12 + const stopIngest = usePlayerStore((x) => x.stopIngest); 12 13 13 14 const createStreamRecord = useCreateStreamRecord(); 14 15 ··· 54 55 } 55 56 }; 56 57 58 + // Stop the current broadcast 59 + const toggleStopStream = () => { 60 + console.log("Stopping stream..."); 61 + stopIngest(); 62 + }; 63 + 57 64 return { 58 65 ingest, 59 66 profile, ··· 67 74 setIngestStarting, 68 75 handleSubmit, 69 76 toggleGoLive, 77 + toggleStopStream, 70 78 }; 71 79 }
+3
js/components/src/player-store/player-state.tsx
··· 63 63 ingestAutoStart?: boolean; 64 64 setIngestAutoStart?: (autoStart: boolean) => void; 65 65 66 + /** stop ingest process, again with a slight delay to allow UI to update */ 67 + stopIngest: () => void; 68 + 66 69 /** Timestamp (number) when ingest started, or null if not started */ 67 70 ingestStarted: number | null; 68 71
+17
js/components/src/player-store/player-store.tsx
··· 53 53 setIngestStarted: (timestamp: number | null) => 54 54 set(() => ({ ingestStarted: timestamp })), 55 55 56 + stopIngest: () => { 57 + set(() => ({ 58 + ingestLive: false, 59 + ingestConnectionState: "new", 60 + ingestStarted: null, 61 + })), 62 + setTimeout( 63 + () => 64 + set(() => ({ 65 + ingestLive: false, 66 + ingestConnectionState: "new", 67 + ingestStarted: null, 68 + })), 69 + 200, 70 + ); 71 + }, 72 + 56 73 fullscreen: false, 57 74 setFullscreen: (isFullscreen: boolean) => 58 75 set(() => ({ fullscreen: isFullscreen })),