Live video on the AT Protocol

web: add PiP

authored by 10xcrazy.horse and committed by Natalie B d58795c4 bd4e8221

+213 -102
+1 -17
js/app/components/create-livestream.tsx
··· 12 import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame"; 13 import { useWindowDimensions, ScrollView } from "react-native"; 14 15 - const Left = ({ children }: { children: React.ReactNode }) => { 16 - return ( 17 - <View f={2} fg={2} fb={0}> 18 - {children} 19 - </View> 20 - ); 21 - }; 22 - 23 - const Right = ({ children }: { children: React.ReactNode }) => { 24 - return ( 25 - <View f={6} fb={0} fg={6}> 26 - {children} 27 - </View> 28 - ); 29 - }; 30 - 31 export default function CreateLivestream() { 32 const dispatch = useAppDispatch(); 33 const toast = useToastController(); ··· 40 const profile = useAppSelector(selectUserProfile); 41 const newLivestream = useAppSelector(selectNewLivestream); 42 const captureFrame = useCaptureVideoFrame(); 43 - const { width, height } = useWindowDimensions(); 44 45 // Responsive layout logic 46 const isWide = width > 1020;
··· 12 import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame"; 13 import { useWindowDimensions, ScrollView } from "react-native"; 14 15 export default function CreateLivestream() { 16 const dispatch = useAppDispatch(); 17 const toast = useToastController(); ··· 24 const profile = useAppSelector(selectUserProfile); 25 const newLivestream = useAppSelector(selectNewLivestream); 26 const captureFrame = useCaptureVideoFrame(); 27 + const { width } = useWindowDimensions(); 28 29 // Responsive layout logic 30 const isWide = width > 1020;
+211 -84
js/app/components/player/controls.tsx
··· 13 Volume2, 14 VolumeX, 15 } from "@tamagui/lucide-icons"; 16 - import { Dispatch, Fragment, useEffect, useRef, useState } from "react"; 17 - import { Animated, ImageBackground, Pressable } from "react-native"; 18 import { 19 Button, 20 H3, ··· 87 setMuted: (muted: boolean) => void; 88 showControls: boolean; 89 }) => { 90 - const [open, setOpen] = useState(false); 91 const [sliderValue, setSliderValue] = useState(volume); 92 - const media = useMedia(); 93 const { isWeb } = usePlatform(); 94 95 - // Update slider value when volume prop changes 96 useEffect(() => { 97 - setSliderValue(muted ? 0 : volume); 98 - }, [volume, muted]); 99 100 - // Close popover when controls are hidden 101 useEffect(() => { 102 - if (!media.sm && !showControls) { 103 - setOpen(false); 104 } 105 - }, [showControls, media.sm]); 106 107 - // Reset slider value when popover opens 108 useEffect(() => { 109 - if (open) { 110 - setSliderValue(muted ? 0 : volume); 111 } 112 - }, [open, volume, muted]); 113 114 const handleValueChange = (value) => { 115 const newValue = value[0]; 116 setSliderValue(newValue); 117 - 118 - // Update volume and mute state 119 if (newValue === 0) { 120 setMuted(true); 121 } else { ··· 126 } 127 }; 128 129 if (!isWeb) { 130 return ( 131 <View paddingLeft="$5" paddingRight="$3" justifyContent="center"> 132 - <Pressable onPress={() => setMuted(!muted)}> 133 {muted ? <VolumeX size={20} /> : <Volume2 size={20} />} 134 </Pressable> 135 </View> ··· 137 } 138 139 return ( 140 - <Popover 141 - size="$5" 142 - allowFlip 143 - placement="top" 144 - open={open} 145 - onOpenChange={setOpen} 146 > 147 - <Popover.Trigger asChild cursor="pointer"> 148 - <View paddingLeft="$5" paddingRight="$3" justifyContent="center"> 149 - <Pressable onPress={() => setOpen(!open)}> 150 - {muted ? <VolumeX size={20} /> : <Volume2 size={20} />} 151 - </Pressable> 152 - </View> 153 - </Popover.Trigger> 154 - 155 - <Popover.Content 156 - borderWidth={0} 157 - padding="$3" 158 - enterStyle={{ y: 10, opacity: 0 }} 159 - exitStyle={{ y: 10, opacity: 0 }} 160 - elevate 161 - animation={[ 162 - "quick", 163 - { 164 - opacity: { 165 - overshootClamping: true, 166 - }, 167 - }, 168 - ]} 169 > 170 - <View width={50} height={150} alignItems="center" gap="$3"> 171 - <XStack alignItems="center" gap="$2"> 172 - <Pressable onPress={() => setMuted(!muted)}> 173 - {muted ? <VolumeX size={20} /> : <Volume2 size={20} />} 174 - </Pressable> 175 - <Text>{Math.round(sliderValue * 100)}</Text> 176 - </XStack> 177 - <View 178 - height={100} 179 - width="100%" 180 - alignItems="center" 181 - justifyContent="center" 182 - > 183 - <Slider 184 - size="$2" 185 - orientation="vertical" 186 - height="100%" 187 - value={[sliderValue]} 188 - onValueChange={handleValueChange} 189 - min={0} 190 - max={1} 191 - step={0.01} 192 - > 193 - <Slider.Track backgroundColor="$gray8" width={4}> 194 - <Slider.TrackActive backgroundColor="$gray5" /> 195 - </Slider.Track> 196 - <Slider.Thumb 197 - circular 198 - index={0} 199 - size="$1" 200 - backgroundColor="white" 201 - /> 202 - </Slider> 203 - </View> 204 - </View> 205 - </Popover.Content> 206 - </Popover> 207 ); 208 }; 209 210 - export default function Controls(props: PlayerProps) { 211 const fadeAnim = useRef(new Animated.Value(1)).current; 212 213 // useEffect(() => { ··· 232 const dispatch = useAppDispatch(); 233 const m = useMedia(); 234 235 return ( 236 <View 237 position="absolute" ··· 337 </Part> 338 <Part justifyContent="flex-end"> 339 <PopoverMenu {...props} /> 340 <Pressable 341 style={{ 342 justifyContent: "center",
··· 13 Volume2, 14 VolumeX, 15 } from "@tamagui/lucide-icons"; 16 + import { 17 + Dispatch, 18 + Fragment, 19 + useEffect, 20 + useRef, 21 + useState, 22 + useCallback, 23 + } from "react"; 24 + import { Animated, Pressable, Easing } from "react-native"; 25 import { 26 Button, 27 H3, ··· 94 setMuted: (muted: boolean) => void; 95 showControls: boolean; 96 }) => { 97 + const [hovered, setHovered] = useState(false); 98 const [sliderValue, setSliderValue] = useState(volume); 99 const { isWeb } = usePlatform(); 100 + const sliderRef = useRef(null); 101 + const iconRef = useRef(null); 102 + const lastVolumeRef = useRef(volume || 1); 103 + const fadeAnim = useRef(new Animated.Value(0)).current; 104 + const hideTimer = useRef<NodeJS.Timeout | null>(null); 105 106 useEffect(() => { 107 + if (hovered && showControls) { 108 + Animated.timing(fadeAnim, { 109 + toValue: 1, 110 + duration: 150, 111 + useNativeDriver: true, 112 + easing: Easing.out(Easing.cubic), 113 + }).start(); 114 + } else { 115 + Animated.timing(fadeAnim, { 116 + toValue: 0, 117 + duration: 150, 118 + useNativeDriver: true, 119 + easing: Easing.out(Easing.cubic), 120 + }).start(); 121 + } 122 + }, [hovered, showControls, fadeAnim]); 123 124 + // Update slider value when volume or mute changes 125 useEffect(() => { 126 + if (muted) { 127 + setSliderValue(0); 128 + } else { 129 + setSliderValue(volume); 130 } 131 + }, [volume, muted]); 132 133 + // Remember last non-zero volume 134 useEffect(() => { 135 + if (!muted && volume > 0) { 136 + lastVolumeRef.current = volume; 137 } 138 + }, [volume, muted]); 139 140 + // Handle volume slider value change 141 const handleValueChange = (value) => { 142 const newValue = value[0]; 143 setSliderValue(newValue); 144 if (newValue === 0) { 145 setMuted(true); 146 } else { ··· 151 } 152 }; 153 154 + // Handle icon click to toggle mute/unmute 155 + const handleIconClick = (e) => { 156 + e.stopPropagation(); 157 + if (muted) { 158 + setMuted(false); 159 + setVolume(lastVolumeRef.current || 1); 160 + } else { 161 + setMuted(true); 162 + } 163 + }; 164 + 165 + const handleMouseEnter = () => { 166 + if (hideTimer.current) { 167 + clearTimeout(hideTimer.current); 168 + hideTimer.current = null; 169 + } 170 + setHovered(true); 171 + }; 172 + 173 + const handleMouseLeave = () => { 174 + hideTimer.current = setTimeout(() => setHovered(false), 300); 175 + }; 176 + 177 if (!isWeb) { 178 return ( 179 <View paddingLeft="$5" paddingRight="$3" justifyContent="center"> 180 + <Pressable onPress={handleIconClick}> 181 {muted ? <VolumeX size={20} /> : <Volume2 size={20} />} 182 </Pressable> 183 </View> ··· 185 } 186 187 return ( 188 + <View 189 + style={{ 190 + position: "relative", 191 + display: "flex", 192 + flexDirection: "row", 193 + alignItems: "center", 194 + }} 195 + onMouseEnter={handleMouseEnter} 196 + onMouseLeave={handleMouseLeave} 197 > 198 + <View 199 + paddingLeft="$5" 200 + paddingRight="$2" 201 + justifyContent="center" 202 + ref={iconRef} 203 + > 204 + <Pressable onPress={handleIconClick}> 205 + {muted ? <VolumeX size={20} /> : <Volume2 size={20} />} 206 + </Pressable> 207 + </View> 208 + <Animated.View 209 + ref={sliderRef} 210 + style={{ 211 + position: "absolute", 212 + left: "100%", 213 + top: "50%", 214 + transform: [{ translateY: "-50%" }], 215 + marginLeft: 2, 216 + borderRadius: 8, 217 + padding: 0, 218 + zIndex: 10, 219 + minWidth: 100, 220 + display: "flex", 221 + flexDirection: "row", 222 + alignItems: "center", 223 + opacity: fadeAnim, 224 + pointerEvents: hovered && showControls ? "auto" : "none", 225 + }} 226 > 227 + <Slider 228 + size="$2" 229 + orientation="horizontal" 230 + width={70} 231 + value={[sliderValue]} 232 + onValueChange={handleValueChange} 233 + min={0} 234 + max={1} 235 + step={0.01} 236 + > 237 + <Slider.Track backgroundColor="$gray8" height={4}> 238 + <Slider.TrackActive backgroundColor="$gray5" /> 239 + </Slider.Track> 240 + <Slider.Thumb circular index={0} size="$1" backgroundColor="white" /> 241 + </Slider> 242 + </Animated.View> 243 + </View> 244 ); 245 }; 246 247 + function isRefObject(ref: any): ref is { current: HTMLVideoElement | null } { 248 + return ref && typeof ref === "object" && "current" in ref; 249 + } 250 + 251 + export default function Controls( 252 + props: PlayerProps & { 253 + videoRef?: 254 + | React.RefObject<HTMLVideoElement> 255 + | React.MutableRefObject<HTMLVideoElement | null> 256 + | ((instance: HTMLVideoElement | null) => void); 257 + }, 258 + ) { 259 const fadeAnim = useRef(new Animated.Value(1)).current; 260 261 // useEffect(() => { ··· 280 const dispatch = useAppDispatch(); 281 const m = useMedia(); 282 283 + const [pipSupported, setPipSupported] = useState(false); 284 + const [pipActive, setPipActive] = useState(false); 285 + 286 + useEffect(() => { 287 + let video: HTMLVideoElement | null = null; 288 + if (isRefObject(props.videoRef)) { 289 + video = props.videoRef.current; 290 + } 291 + if (video) { 292 + setPipSupported( 293 + !!document.pictureInPictureEnabled && 294 + typeof video.requestPictureInPicture === "function", 295 + ); 296 + } else { 297 + setPipSupported(false); 298 + } 299 + }, [props.videoRef, props.ingest]); 300 + 301 + useEffect(() => { 302 + let video: HTMLVideoElement | null = null; 303 + if (isRefObject(props.videoRef)) { 304 + video = props.videoRef.current; 305 + } 306 + if (!video) return; 307 + function onEnter() { 308 + setPipActive(true); 309 + } 310 + function onLeave() { 311 + setPipActive(false); 312 + } 313 + video.addEventListener("enterpictureinpicture", onEnter); 314 + video.addEventListener("leavepictureinpicture", onLeave); 315 + return () => { 316 + if (video) { 317 + // ts is mad if we don't check this 318 + video.removeEventListener("enterpictureinpicture", onEnter); 319 + video.removeEventListener("leavepictureinpicture", onLeave); 320 + } 321 + }; 322 + }, [props.videoRef]); 323 + 324 + const handlePip = useCallback(() => { 325 + let video: HTMLVideoElement | null = null; 326 + if (isRefObject(props.videoRef)) { 327 + video = props.videoRef.current; 328 + } 329 + if (!video) return; 330 + video.requestPictureInPicture().catch((err) => { 331 + console.error("Failed to enter Picture-in-Picture mode", err); 332 + }); 333 + }, [props.videoRef]); 334 + 335 return ( 336 <View 337 position="absolute" ··· 437 </Part> 438 <Part justifyContent="flex-end"> 439 <PopoverMenu {...props} /> 440 + {pipSupported && ( 441 + <Pressable 442 + onPress={handlePip} 443 + disabled={pipActive} 444 + style={{ 445 + justifyContent: "center", 446 + pointerEvents: "auto", 447 + }} 448 + accessibilityLabel="Picture in Picture" 449 + > 450 + <View paddingLeft="$3" paddingRight="$3" justifyContent="center"> 451 + <svg 452 + width="24" 453 + height="24" 454 + viewBox="0 0 24 24" 455 + fill="none" 456 + stroke="currentColor" 457 + strokeWidth="2" 458 + strokeLinecap="round" 459 + strokeLinejoin="round" 460 + > 461 + <rect x="3" y="3" width="18" height="14" rx="2" /> 462 + <rect x="15" y="13" width="6" height="6" rx="1" /> 463 + </svg> 464 + </View> 465 + </Pressable> 466 + )} 467 <Pressable 468 style={{ 469 justifyContent: "center",
+1 -1
js/app/components/player/fullscreen.tsx
··· 74 return ( 75 <View flex={1} ref={divRef}> 76 <PlayerLoading {...props}></PlayerLoading> 77 - <Controls {...props} setFullscreen={setFullscreen} /> 78 <VideoRetry {...props}> 79 <Video {...props} videoRef={videoCallback} /> 80 </VideoRetry>
··· 74 return ( 75 <View flex={1} ref={divRef}> 76 <PlayerLoading {...props}></PlayerLoading> 77 + <Controls {...props} setFullscreen={setFullscreen} videoRef={videoRef} /> 78 <VideoRetry {...props}> 79 <Video {...props} videoRef={videoCallback} /> 80 </VideoRetry>