Live video on the AT Protocol

chat: fix chat on ios, improve chat everywhere

See merge request streamplace/streamplace!114

Changelog: feature

Eli Streams 6be8d2e1 787c69c4

+218 -148
+7 -6
js/app/components/chat/chat-box.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 1 2 import { Send } from "@tamagui/lucide-icons"; 2 - import { useRef, useState } from "react"; 3 - import { Button, Form, isWeb, View, Input, TextArea } from "tamagui"; 4 - import { Keyboard } from "react-native"; 5 - import { usePlayerLivestream } from "features/player/playerSlice"; 6 - import { useAppDispatch, useAppSelector } from "store/hooks"; 7 3 import { 8 4 chatPost, 9 5 selectIsReady, 10 6 selectUserProfile, 11 7 } from "features/bluesky/blueskySlice"; 12 - import { useNavigation } from "@react-navigation/native"; 8 + import { usePlayerLivestream } from "features/player/playerSlice"; 9 + import { useRef, useState } from "react"; 10 + import { Keyboard } from "react-native"; 11 + import { useAppDispatch, useAppSelector } from "store/hooks"; 12 + import { Button, Form, Input, isWeb, TextArea, View } from "tamagui"; 13 13 14 14 export default function ChatBox() { 15 15 const [message, setMessage] = useState(""); ··· 79 79 submit(); 80 80 } 81 81 }} 82 + onSubmitEditing={submit} 82 83 /> 83 84 </View> 84 85 <Button
+176 -125
js/app/components/livestream/livestream.tsx
··· 1 + import Chat from "components/chat/chat"; 2 + import ChatBox from "components/chat/chat-box"; 3 + import Loading from "components/loading/loading"; 1 4 import { Player } from "components/player/player"; 5 + import { PlayerProps } from "components/player/props"; 6 + import PlayerProvider from "components/player/provider"; 2 7 import Popup from "components/popup"; 8 + import Viewers from "components/viewers"; 9 + import { usePlayer } from "features/player/playerSlice"; 3 10 import { 4 11 selectTelemetry, 5 12 telemetryOpt, 6 13 } from "features/streamplace/streamplaceSlice"; 7 - import { Button, View, Text, H2, useWindowDimensions } from "tamagui"; 8 - import { useAppSelector, useAppDispatch } from "store/hooks"; 9 - import { H3 } from "tamagui"; 10 - import { PlayerProps } from "components/player/props"; 11 - import PlayerProvider from "components/player/provider"; 12 - import Chat from "components/chat/chat"; 13 - import { usePlayer } from "features/player/playerSlice"; 14 - import { useState, useEffect } from "react"; 15 - import Loading from "components/loading/loading"; 16 - import Viewers from "components/viewers"; 17 - import ChatBox from "components/chat/chat-box"; 18 14 import { useKeyboard } from "hooks/useKeyboard"; 15 + import usePlatform from "hooks/usePlatform"; 16 + import { useCallback, useEffect, useState } from "react"; 17 + import { LayoutChangeEvent, View as RNView, SafeAreaView } from "react-native"; 18 + import { useAppDispatch, useAppSelector } from "store/hooks"; 19 + import { Button, H2, H3, Text, useWindowDimensions, View } from "tamagui"; 19 20 20 21 export default function Livestream(props: Partial<PlayerProps>) { 21 22 return ( ··· 34 35 const video = player.segment?.video?.[0]; 35 36 const [videoWidth, setVideoWidth] = useState(0); 36 37 const [videoHeight, setVideoHeight] = useState(0); 37 - const { isKeyboardVisible } = useKeyboard(); 38 + const { isKeyboardVisible, keyboardHeight } = useKeyboard(); 39 + const { isIOS } = usePlatform(); 40 + 41 + const [outerHeight, setOuterHeight] = useState(0); 42 + const [innerHeight, setInnerHeight] = useState(0); 43 + 44 + // this would all be really easy if i had library that would give me the 45 + // safe area view height and width but i don't. so let's measure 46 + const onInnerLayout = useCallback((event: LayoutChangeEvent) => { 47 + const { width, height } = event.nativeEvent.layout; 48 + setInnerHeight(height); 49 + }, []); 50 + 51 + const onOuterLayout = useCallback((event: LayoutChangeEvent) => { 52 + const { width, height } = event.nativeEvent.layout; 53 + setOuterHeight(height); 54 + }, []); 55 + 38 56 useEffect(() => { 39 57 if (video) { 40 58 const ratio = video.width / width; ··· 43 61 } 44 62 }, [video, width, height]); 45 63 64 + let slideKeyboard = 0; 65 + if (isIOS && keyboardHeight > 0) { 66 + slideKeyboard = -keyboardHeight + (outerHeight - innerHeight); 67 + } 68 + 46 69 return ( 47 - <View f={1} position="relative"> 48 - {videoWidth === 0 && ( 49 - <View f={1} position="absolute" top={0} left={0} right={0} bottom={0}> 50 - <Loading /> 51 - </View> 52 - )} 53 - {telemetry === null && ( 54 - <Popup 55 - onClose={() => { 56 - dispatch(telemetryOpt(false)); 57 - }} 58 - containerProps={{ 59 - bottom: "$8", 60 - zIndex: 1000, 61 - }} 62 - bubbleProps={{ 63 - cursor: "pointer", 64 - backgroundColor: "$accentBackground", 65 - gap: "$3", 66 - maxWidth: 400, 67 - }} 70 + <RNView style={{ flex: 1 }}> 71 + <SafeAreaView style={{ flex: 1 }} onLayout={onOuterLayout}> 72 + <RNView 73 + style={{ flex: 1, position: "relative" }} 74 + onLayout={onInnerLayout} 68 75 > 69 - <H3 textAlign="center">Player Telemetry</H3> 70 - <Text> 71 - Streamplace is beta software and it helps us out to have the player 72 - report back on how playback is working. Would you like to opt in to 73 - optional player telemetry? 74 - </Text> 75 - <View flexDirection="row" gap="$2" f={1}> 76 - <Button 77 - f={3} 78 - backgroundColor="$accentColor" 79 - onPress={() => { 80 - dispatch(telemetryOpt(true)); 81 - }} 76 + {videoWidth === 0 && ( 77 + <View 78 + f={1} 79 + position="absolute" 80 + top={0} 81 + left={0} 82 + right={0} 83 + bottom={0} 82 84 > 83 - Opt in 84 - </Button> 85 - <Button 86 - f={3} 87 - onPress={() => { 85 + <Loading /> 86 + </View> 87 + )} 88 + {telemetry === null && ( 89 + <Popup 90 + onClose={() => { 88 91 dispatch(telemetryOpt(false)); 89 92 }} 93 + containerProps={{ 94 + bottom: "$8", 95 + zIndex: 1000, 96 + }} 97 + bubbleProps={{ 98 + cursor: "pointer", 99 + backgroundColor: "$accentBackground", 100 + gap: "$3", 101 + maxWidth: 400, 102 + }} 90 103 > 91 - Opt out 92 - </Button> 93 - </View> 94 - </Popup> 95 - )} 96 - <View 97 - f={1} 98 - opacity={videoWidth === 0 ? 0 : 1} 99 - flexDirection="column" 100 - $gtXs={{ flexDirection: "row" }} 101 - > 102 - <View 103 - width={videoWidth} 104 - height={videoHeight} 105 - maxHeight="100%" 106 - fs={0} 107 - $gtXs={{ fs: 1 }} 108 - > 109 - <Player 110 - telemetry={telemetry === true} 111 - src={src} 112 - forceProtocol={protocol} 113 - {...extraProps} 114 - /> 104 + <H3 textAlign="center">Player Telemetry</H3> 105 + <Text> 106 + Streamplace is beta software and it helps us out to have the 107 + player report back on how playback is working. Would you like to 108 + opt in to optional player telemetry? 109 + </Text> 110 + <View flexDirection="row" gap="$2" f={1}> 111 + <Button 112 + f={3} 113 + backgroundColor="$accentColor" 114 + onPress={() => { 115 + dispatch(telemetryOpt(true)); 116 + }} 117 + > 118 + Opt in 119 + </Button> 120 + <Button 121 + f={3} 122 + onPress={() => { 123 + dispatch(telemetryOpt(false)); 124 + }} 125 + > 126 + Opt out 127 + </Button> 128 + </View> 129 + </Popup> 130 + )} 115 131 <View 116 - height={100} 117 - fg={0} 118 - p="$4" 119 - display="none" 120 - flexDirection="row" 121 - alignItems="flex-start" 122 - justifyContent="space-between" 123 - $gtXs={{ display: "flex" }} 132 + f={1} 133 + opacity={videoWidth === 0 ? 0 : 1} 134 + flexDirection="column" 135 + $gtXs={{ flexDirection: "row" }} 136 + zIndex={2} 124 137 > 125 - <H2>{player.livestream?.record.title}</H2> 126 - <View justifyContent="center" paddingRight="$3"> 127 - <Viewers viewers={player.viewers ?? 0} /> 138 + <View 139 + width={videoWidth} 140 + height={videoHeight} 141 + maxHeight="100%" 142 + fs={0} 143 + $gtXs={{ fs: 1 }} 144 + zIndex={2} 145 + > 146 + <Player 147 + telemetry={telemetry === true} 148 + src={src} 149 + forceProtocol={protocol} 150 + {...extraProps} 151 + /> 152 + <View 153 + height={100} 154 + fg={0} 155 + p="$4" 156 + display="none" 157 + flexDirection="row" 158 + alignItems="flex-start" 159 + justifyContent="space-between" 160 + $gtXs={{ display: "flex" }} 161 + > 162 + <H2>{player.livestream?.record.title}</H2> 163 + <View justifyContent="center" paddingRight="$3"> 164 + <Viewers viewers={player.viewers ?? 0} /> 165 + </View> 166 + </View> 128 167 </View> 129 - </View> 130 - </View> 131 - <View 132 - $gtXs={{ display: "none" }} 133 - flexDirection="row" 134 - gap="$2" 135 - borderBottomColor="#666" 136 - borderBottomWidth={1} 137 - display={isKeyboardVisible ? "none" : "flex"} 138 - borderTopColor="#666" 139 - borderTopWidth={1} 140 - > 141 - <View f={1} fb={0} padding="$3" justifyContent="center"> 142 - <Text fontSize={18} numberOfLines={1} ellipsizeMode="tail"> 143 - {player.livestream?.record.title} 144 - </Text> 168 + 169 + <View 170 + f={1} 171 + fg={1} 172 + zIndex={1} 173 + $gtXs={{ 174 + width: 300, 175 + fb: 300, 176 + fs: 0, 177 + borderLeftColor: "#666", 178 + borderLeftWidth: 1, 179 + }} 180 + backgroundColor="$background2" 181 + animation={"quick"} 182 + transform={ 183 + isIOS 184 + ? [ 185 + { 186 + translateY: slideKeyboard, 187 + }, 188 + ] 189 + : undefined 190 + } 191 + > 192 + <View 193 + $gtXs={{ display: "none" }} 194 + flexDirection="row" 195 + gap="$2" 196 + borderBottomColor="#666" 197 + borderBottomWidth={1} 198 + borderTopColor="#666" 199 + borderTopWidth={1} 200 + zIndex={1} 201 + > 202 + <View f={1} fb={0} padding="$3" justifyContent="center"> 203 + <Text fontSize={18} numberOfLines={1} ellipsizeMode="tail"> 204 + {player.livestream?.record.title} 205 + </Text> 206 + </View> 207 + <View justifyContent="center" paddingRight="$3"> 208 + <Viewers viewers={player.viewers ?? 0} /> 209 + </View> 210 + </View> 211 + <Chat /> 212 + <View> 213 + <ChatBox /> 214 + </View> 215 + </View> 145 216 </View> 146 - <View justifyContent="center" paddingRight="$3"> 147 - <Viewers viewers={player.viewers ?? 0} /> 148 - </View> 149 - </View> 150 - <View 151 - f={1} 152 - fg={1} 153 - $gtXs={{ 154 - width: 300, 155 - fb: 300, 156 - fs: 0, 157 - borderLeftColor: "#666", 158 - borderLeftWidth: 1, 159 - }} 160 - backgroundColor="$background2" 161 - > 162 - <Chat /> 163 - <View> 164 - <ChatBox /> 165 - </View> 166 - </View> 167 - </View> 168 - </View> 217 + </RNView> 218 + </SafeAreaView> 219 + </RNView> 169 220 ); 170 221 }
+11 -13
js/app/components/player/provider.tsx
··· 5 5 PlayerContext, 6 6 usePlayerActions, 7 7 } from "features/player/playerSlice"; 8 - import { useState, useEffect, useContext, useRef } from "react"; 8 + import { selectUrl } from "features/streamplace/streamplaceSlice"; 9 + import { useContext, useEffect, useRef, useState } from "react"; 10 + import useWebSocket, { ReadyState } from "react-use-websocket"; 9 11 import { useAppDispatch, useAppSelector } from "store/hooks"; 10 12 import { PlayerProps } from "./props"; 11 - import { selectUrl } from "features/streamplace/streamplaceSlice"; 12 - import useWebSocket from "react-use-websocket"; 13 - import { ReadyState } from "react-use-websocket"; 14 13 15 14 const POLL_INTERVAL = 3000; 16 15 // PlayerInner starts doing player stuff ··· 76 75 wsUrl = wsUrl.replace(/^https\:/, "wss:"); 77 76 78 77 const ref = useRef<any[]>([]); 79 - const last = useRef<number>(0); 80 78 const handle = useRef<NodeJS.Timeout | null>(null); 81 79 82 80 const { readyState } = useWebSocket(`${wsUrl}/api/websocket/${props.src}`, { ··· 84 82 shouldReconnect: () => true, 85 83 86 84 onOpen: () => { 87 - console.log("onOpen"); 88 85 ref.current = []; 89 86 }, 90 87 88 + onError: (e) => { 89 + console.log("onError", e); 90 + }, 91 + 92 + // spamming the redux store with messages causes a zillion re-renders, 93 + // so we batch them up a bit 91 94 onMessage: (msg) => { 92 95 try { 93 96 const data = JSON.parse(msg.data); ··· 95 98 if (handle.current) { 96 99 return; 97 100 } 98 - let scheduleUpdate = Date.now() - last.current; 99 - if (scheduleUpdate < 0) { 100 - scheduleUpdate = 0; 101 - } 102 101 handle.current = setTimeout(() => { 103 102 dispatch(handleWebSocketMessages(ref.current)); 104 103 ref.current = []; 105 - last.current = Date.now(); 106 104 handle.current = null; 107 - }, scheduleUpdate); 105 + }, 250); 108 106 } catch (e) { 109 - last.current = Date.now(); 107 + console.log("onMessage parse error", e); 110 108 } 111 109 }, 112 110 });
+24 -4
js/app/hooks/useKeyboard.tsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useEffect, useState } from "react"; 2 2 import { Keyboard } from "react-native"; 3 3 4 4 export function useKeyboard() { 5 5 const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); 6 - 6 + const [keyboardHeight, setKeyboardHeight] = useState(0); 7 7 useEffect(() => { 8 - const showSubscription = Keyboard.addListener("keyboardDidShow", () => { 8 + const willShowSubscription = Keyboard.addListener( 9 + "keyboardWillShow", 10 + (e) => { 11 + // setIsKeyboardVisible(true); 12 + setKeyboardHeight(e.endCoordinates.height); 13 + console.log("keyboardWillShow", e.endCoordinates.height); 14 + }, 15 + ); 16 + const willHideSubscription = Keyboard.addListener( 17 + "keyboardWillHide", 18 + (e) => { 19 + // setIsKeyboardVisible(false); 20 + setKeyboardHeight(0); 21 + console.log("keyboardWillHide", e.endCoordinates.height); 22 + }, 23 + ); 24 + const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => { 9 25 setIsKeyboardVisible(true); 26 + setKeyboardHeight(e.endCoordinates.height); 27 + console.log("keyboardDidShow", e.endCoordinates.height); 10 28 }); 11 29 const hideSubscription = Keyboard.addListener("keyboardDidHide", () => { 12 30 setIsKeyboardVisible(false); 31 + setKeyboardHeight(0); 13 32 }); 14 33 return () => { 15 34 showSubscription.remove(); 16 35 hideSubscription.remove(); 36 + willShowSubscription.remove(); 17 37 }; 18 38 }, []); 19 39 20 - return { isKeyboardVisible }; 40 + return { isKeyboardVisible, keyboardHeight }; 21 41 }