Live video on the AT Protocol

re-add offline counter, video retry

+196 -2
+41 -1
js/app/components/mobile/desktop-ui.tsx
··· 10 useLivestreamInfo, 11 usePlayerDimensions, 12 usePlayerStore, 13 View, 14 zero, 15 } from "@streamplace/components"; ··· 31 useSharedValue, 32 withTiming, 33 } from "react-native-reanimated"; 34 import { useResponsiveLayout } from "./useResponsiveLayout"; 35 36 const { borders, colors, gap, h, layout, position, w, px, py, r, p, bg, text } = ··· 194 const muteWasForced = usePlayerStore((state) => state.muteWasForced); 195 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 196 const setMuted = usePlayerStore((state) => state.setMuted); 197 198 const [isControlsVisible, setIsControlsVisible] = useState(true); 199 const [isChatOpen, setIsChatOpen] = useState(false); ··· 238 opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, 239 })); 240 241 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 242 243 return ( ··· 304 > 305 {profile?.handle} 306 </Text> 307 - <LiveBubble /> 308 </View> 309 </View> 310 ··· 426 description="We're notifying your followers that you just went live." 427 duration={5} 428 /> 429 {muteWasForced && ( 430 <View 431 style={[
··· 10 useLivestreamInfo, 11 usePlayerDimensions, 12 usePlayerStore, 13 + useSegment, 14 View, 15 zero, 16 } from "@streamplace/components"; ··· 32 useSharedValue, 33 withTiming, 34 } from "react-native-reanimated"; 35 + import { OfflineCounter } from "./offline-counter"; 36 import { useResponsiveLayout } from "./useResponsiveLayout"; 37 38 const { borders, colors, gap, h, layout, position, w, px, py, r, p, bg, text } = ··· 196 const muteWasForced = usePlayerStore((state) => state.muteWasForced); 197 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 198 const setMuted = usePlayerStore((state) => state.setMuted); 199 + const offline = usePlayerStore((state) => state.offline); 200 + 201 + const segment = useSegment(); 202 203 const [isControlsVisible, setIsControlsVisible] = useState(true); 204 const [isChatOpen, setIsChatOpen] = useState(false); ··· 243 opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value, 244 })); 245 246 + // Live timer for offline overlay 247 + const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); 248 + 249 + useEffect(() => { 250 + if (!offline || !segment?.startTime) { 251 + setTimeSinceLastSeen("Unknown"); 252 + return; 253 + } 254 + 255 + const updateTimer = () => { 256 + const now = new Date(); 257 + const lastSeen = new Date(segment.startTime); 258 + const diffMs = now.getTime() - lastSeen.getTime(); 259 + const diffMinutes = Math.floor(diffMs / 60000); 260 + const diffSeconds = Math.floor((diffMs % 60000) / 1000); 261 + 262 + if (diffMinutes > 0) { 263 + setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); 264 + } else { 265 + setTimeSinceLastSeen(`${diffSeconds}s ago`); 266 + } 267 + }; 268 + 269 + // Update immediately 270 + updateTimer(); 271 + 272 + // Update every second while offline 273 + const interval = setInterval(updateTimer, 1000); 274 + 275 + return () => clearInterval(interval); 276 + }, [offline, segment?.startTime]); 277 + 278 const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)()); 279 280 return ( ··· 341 > 342 {profile?.handle} 343 </Text> 344 + {!offline && <LiveBubble />} 345 </View> 346 </View> 347 ··· 463 description="We're notifying your followers that you just went live." 464 duration={5} 465 /> 466 + 467 + {offline && <OfflineCounter />} 468 + 469 {muteWasForced && ( 470 <View 471 style={[
+114
js/app/components/mobile/offline-counter/index.tsx
···
··· 1 + import { 2 + Text, 3 + usePlayerStore, 4 + useSegment, 5 + View, 6 + zero, 7 + } from "@streamplace/components"; 8 + import { useEffect, useState } from "react"; 9 + 10 + const { gap, h, layout, position, w } = zero; 11 + 12 + interface OfflineCounterProps { 13 + isMobile?: boolean; 14 + } 15 + 16 + export function OfflineCounter({ isMobile = false }: OfflineCounterProps) { 17 + const offline = usePlayerStore((state) => state.offline); 18 + const segment = useSegment(); 19 + 20 + // Live timer for offline overlay 21 + const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown"); 22 + 23 + useEffect(() => { 24 + if (!offline || !segment?.startTime) { 25 + setTimeSinceLastSeen("Unknown"); 26 + return; 27 + } 28 + 29 + const updateTimer = () => { 30 + const now = new Date(); 31 + const lastSeen = new Date(segment.startTime); 32 + const diffMs = now.getTime() - lastSeen.getTime(); 33 + const diffMinutes = Math.floor(diffMs / 60000); 34 + const diffSeconds = Math.floor((diffMs % 60000) / 1000); 35 + 36 + if (diffMinutes > 0) { 37 + setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`); 38 + } else { 39 + setTimeSinceLastSeen(`${diffSeconds}s ago`); 40 + } 41 + }; 42 + 43 + // Update immediately 44 + updateTimer(); 45 + 46 + // Update every second while offline 47 + const interval = setInterval(updateTimer, 1000); 48 + 49 + return () => clearInterval(interval); 50 + }, [offline, segment?.startTime]); 51 + 52 + if (!offline) return null; 53 + 54 + const titleFontSize = isMobile ? 24 : 32; 55 + const subtitleFontSize = isMobile ? 16 : 18; 56 + const descriptionFontSize = isMobile ? 14 : 16; 57 + const descriptionMarginTop = isMobile ? 8 : 12; 58 + const maxWidth = isMobile ? undefined : 400; 59 + 60 + return ( 61 + <View 62 + style={[ 63 + layout.position.absolute, 64 + layout.flex.center, 65 + h.percent[100], 66 + w.percent[100], 67 + { 68 + backgroundColor: "rgba(0, 0, 0, 0.85)", 69 + zIndex: 1000, 70 + }, 71 + ]} 72 + > 73 + <View style={[layout.flex.column, layout.flex.center, gap.all[3]]}> 74 + <Text 75 + style={[ 76 + { 77 + fontSize: titleFontSize, 78 + fontWeight: "700", 79 + color: "white", 80 + textAlign: "center", 81 + }, 82 + ]} 83 + > 84 + Stream Offline 85 + </Text> 86 + <Text 87 + style={[ 88 + { 89 + fontSize: subtitleFontSize, 90 + color: "rgba(255, 255, 255, 0.8)", 91 + textAlign: "center", 92 + fontVariant: ["tabular-nums"], 93 + }, 94 + ]} 95 + > 96 + Last seen: {timeSinceLastSeen} 97 + </Text> 98 + <Text 99 + style={[ 100 + { 101 + fontSize: descriptionFontSize, 102 + color: "rgba(255, 255, 255, 0.6)", 103 + textAlign: "center", 104 + marginTop: descriptionMarginTop, 105 + maxWidth, 106 + }, 107 + ]} 108 + > 109 + The stream will resume automatically when the broadcaster returns 110 + </Text> 111 + </View> 112 + </View> 113 + ); 114 + }
+2
js/app/components/mobile/player.tsx
··· 22 import { BottomMetadata } from "./bottom-metadata"; 23 import { DesktopChatPanel } from "./chat"; 24 import { DesktopUi } from "./desktop-ui"; 25 import { MobileUi } from "./ui"; 26 import { useResponsiveLayout } from "./useResponsiveLayout"; 27 ··· 226 > 227 <PlayerInnerInner {...props}> 228 {(showBottomMetaPanel || fullscreen) && <DesktopUi />} 229 </PlayerInnerInner> 230 </Animated.View> 231 {showBottomMetaPanel && (
··· 22 import { BottomMetadata } from "./bottom-metadata"; 23 import { DesktopChatPanel } from "./chat"; 24 import { DesktopUi } from "./desktop-ui"; 25 + import { OfflineCounter } from "./offline-counter"; 26 import { MobileUi } from "./ui"; 27 import { useResponsiveLayout } from "./useResponsiveLayout"; 28 ··· 227 > 228 <PlayerInnerInner {...props}> 229 {(showBottomMetaPanel || fullscreen) && <DesktopUi />} 230 + <OfflineCounter isMobile={true} /> 231 </PlayerInnerInner> 232 </Animated.View> 233 {showBottomMetaPanel && (
+1
js/app/components/mobile/ui.tsx
··· 251 /> 252 </Animated.View> 253 </TouchableWithoutFeedback> 254 {!isSelfAndNotLive && ( 255 <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} /> 256 )}
··· 251 /> 252 </Animated.View> 253 </TouchableWithoutFeedback> 254 + 255 {!isSelfAndNotLive && ( 256 <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} /> 257 )}
+4 -1
js/components/src/components/mobile-player/fullscreen.tsx
··· 3 import { getFirstPlayerID, usePlayerStore } from "../.."; 4 import { View } from "../../components/ui"; 5 import Video from "./video"; 6 7 export function Fullscreen(props: { src: string; children?: React.ReactNode }) { 8 const playerId = getFirstPlayerID(); ··· 76 ref={divRef} 77 style={{ width: "100%", height: "100%", overflow: "hidden" }} 78 > 79 - <Video /> 80 {props.children} 81 </View> 82 );
··· 3 import { getFirstPlayerID, usePlayerStore } from "../.."; 4 import { View } from "../../components/ui"; 5 import Video from "./video"; 6 + import VideoRetry from "./video-retry"; 7 8 export function Fullscreen(props: { src: string; children?: React.ReactNode }) { 9 const playerId = getFirstPlayerID(); ··· 77 ref={divRef} 78 style={{ width: "100%", height: "100%", overflow: "hidden" }} 79 > 80 + <VideoRetry> 81 + <Video /> 82 + </VideoRetry> 83 {props.children} 84 </View> 85 );
+33
js/components/src/components/mobile-player/video-retry.tsx
···
··· 1 + import React, { useEffect } from "react"; 2 + import { usePlayerStore, useSegment, useStreamplaceStore } from "../.."; 3 + 4 + import { useRef } from "react"; 5 + 6 + export default function VideoRetry(props: { children: React.ReactNode }) { 7 + const lastSegmentRef = useRef<string | null>(null); 8 + const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null); 9 + const segment = useSegment(); 10 + 11 + const offline = usePlayerStore((x) => x.offline); 12 + const spurl = useStreamplaceStore((x) => x.url); 13 + 14 + useEffect(() => { 15 + if ( 16 + lastSegmentRef.current !== null && 17 + segment && 18 + segment.startTime !== lastSegmentRef.current && 19 + offline 20 + ) { 21 + const jitter = 500 + Math.random() * 1500; 22 + retryTimeoutRef.current = setTimeout(() => { 23 + lastSegmentRef.current = segment?.startTime; 24 + }, jitter); 25 + } 26 + }, [offline, segment, spurl]); 27 + 28 + return ( 29 + <React.Fragment key={lastSegmentRef.current}> 30 + {props.children} 31 + </React.Fragment> 32 + ); 33 + }
+1
js/components/src/index.tsx
··· 28 export * from "./components/chat/chat"; 29 export * from "./components/chat/chat-box"; 30 export * from "./components/chat/system-message"; 31 export * from "./lib/system-messages";
··· 28 export * from "./components/chat/chat"; 29 export * from "./components/chat/chat-box"; 30 export * from "./components/chat/system-message"; 31 + export { default as VideoRetry } from "./components/mobile-player/video-retry"; 32 export * from "./lib/system-messages";