diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 8562b89a..def292af 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -171,7 +171,6 @@ export const Chat = memo(({ channelName, channelId }: ChatProps) => { return; } - // Only fetch cosmetics for the first 5 seconds of chat to prevent API overload if (!canFetchCosmetics()) { const chatStartTime = chatStartTimeRef.current; const elapsedSeconds = chatStartTime diff --git a/src/components/Chat/components/ChatInputSection.tsx b/src/components/Chat/components/ChatInputSection.tsx index fab39002..9758f5b3 100644 --- a/src/components/Chat/components/ChatInputSection.tsx +++ b/src/components/Chat/components/ChatInputSection.tsx @@ -68,6 +68,11 @@ export const ChatInputSection = memo( const canSend = messageInput.trim() && isConnected; + const inputPlaceholder = + replyTo !== null + ? `Reply to ${replyTo.username}...` + : 'Send a message...'; + return ( void; onPipPress?: () => void; onPlayPausePress: () => void; onRefresh?: () => void; onToggleControls: () => void; paused: boolean; + /** + * Timestamp when player fired 'ready', or null if not ready yet. + */ + readyTimestamp: number | null; showPip?: boolean; streamInfo?: StreamInfo; } @@ -420,19 +428,36 @@ function formatViewerCount(count?: number): string { function ControlsOverlay({ isVisible, + overlayStartTime, onBackPress, onPipPress, onPlayPausePress, onRefresh, onToggleControls, paused, + readyTimestamp, showPip = Platform.OS === 'ios', streamInfo, }: ControlsOverlayProps) { + const { theme } = useUnistyles(); const opacity = useSharedValue(0); const [duration, setDuration] = useState(() => formatDuration(streamInfo?.startedAt), ); + const [latencySeconds, setLatencySeconds] = useState(0); + + useEffect(() => { + const update = () => { + const elapsedMs = + readyTimestamp != null + ? readyTimestamp - overlayStartTime + : Date.now() - overlayStartTime; + setLatencySeconds(elapsedMs / 1000); + }; + update(); + const interval = setInterval(update, 500); + return () => clearInterval(interval); + }, [overlayStartTime, readyTimestamp]); useEffect(() => { if (!streamInfo?.startedAt) return; @@ -475,6 +500,18 @@ function ControlsOverlay({ + + + + {latencySeconds.toFixed(1)}s + + + {onBackPress && ( @@ -483,7 +520,11 @@ function ControlsOverlay({ style={styles.headerButton} onPress={onBackPress} > - + )} @@ -503,7 +544,11 @@ function ControlsOverlay({ style={styles.playPauseButton} onPress={onPlayPausePress} > - + @@ -530,7 +575,11 @@ function ControlsOverlay({ style={styles.controlButton} onPress={onRefresh} > - + )} @@ -543,7 +592,7 @@ function ControlsOverlay({ onPress={onPipPress} > ( }, ref, ) { + const { theme } = useUnistyles(); const webViewRef = useRef(null); const [playerState, setPlayerState] = useState({ @@ -604,7 +654,14 @@ export const StreamPlayer = forwardRef( const controlsTimeoutRef = useRef | null>( null, ); + const [overlayStartTime, setOverlayStartTime] = useState(() => Date.now()); + const [readyTimestamp, setReadyTimestamp] = useState(null); const [hasContentGate, setHasContentGate] = useState(false); + + useEffect(() => { + setOverlayStartTime(Date.now()); + setReadyTimestamp(null); + }, [channel, video]); const [showLoginPrompt, setShowLoginPrompt] = useState(true); useEffect(() => { @@ -821,6 +878,7 @@ export const StreamPlayer = forwardRef( switch (message.type) { case 'ready': + setReadyTimestamp(Date.now()); setPlayerState(prev => ({ ...prev, isReady: true, @@ -1110,19 +1168,25 @@ export const StreamPlayer = forwardRef( accessibilityLabel="Show player controls" accessibilityRole="button" > - + )} {showOverlayControls && !hasContentGate && playerState.isReady && ( )} @@ -1175,8 +1239,8 @@ const styles = StyleSheet.create((theme, rt) => ({ flexDirection: 'row', gap: theme.spacing.sm, left: 0, - paddingBottom: rt.insets.bottom + 4, - paddingHorizontal: theme.spacing.sm, + paddingBottom: rt.insets.bottom + 12, + paddingHorizontal: theme.spacing.md, position: 'absolute', right: 0, }, @@ -1256,16 +1320,17 @@ const styles = StyleSheet.create((theme, rt) => ({ }, controlButton: { alignItems: 'center', - height: 36, + height: 40, justifyContent: 'center', - width: 36, + width: 40, }, controlButtonContainer: { alignItems: 'center', - borderRadius: theme.radii.sm, - height: 20, + backgroundColor: theme.colors.black.uiActiveAlpha, + borderRadius: theme.radii.md, + height: 40, justifyContent: 'center', - width: 36, + width: 40, }, controlsOverlay: { bottom: 0, @@ -1293,25 +1358,49 @@ const styles = StyleSheet.create((theme, rt) => ({ gradientBottom: { backgroundColor: theme.colors.black.borderHoverAlpha, bottom: 0, - height: 80, + height: 120, left: 0, position: 'absolute', right: 0, }, gradientTop: { backgroundColor: theme.colors.black.borderHoverAlpha, - height: 80, + height: 120, left: 0, position: 'absolute', right: 0, top: 0, }, + latencyBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.xs, + position: 'absolute', + top: rt.insets.top + theme.spacing.sm, + right: theme.spacing.sm + 48, + backgroundColor: theme.colors.black.uiActiveAlpha, + borderRadius: theme.radii.full, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, + zIndex: 2, + borderWidth: 1, + borderColor: theme.colors.black.borderHoverAlpha, + }, + latencyBadgeIcon: { + opacity: 0.9, + }, + latencyBadgeText: { + color: theme.colors.gray.contrast, + fontSize: theme.font.fontSize.xs, + fontWeight: '600', + letterSpacing: 0.2, + }, header: { alignItems: 'center', flexDirection: 'row', left: 0, - paddingHorizontal: theme.spacing.xs, - paddingTop: rt.insets.top + 2, + paddingHorizontal: theme.spacing.sm, + paddingTop: rt.insets.top + 8, position: 'absolute', right: 0, top: 0, @@ -1319,17 +1408,17 @@ const styles = StyleSheet.create((theme, rt) => ({ }, headerButton: { alignItems: 'center', - height: 36, + height: 40, justifyContent: 'center', - width: 36, + width: 40, }, headerButtonContainer: { alignItems: 'center', backgroundColor: theme.colors.black.uiActiveAlpha, - borderRadius: theme.radii.sm, - height: 36, + borderRadius: theme.radii.md, + height: 40, justifyContent: 'center', - width: 36, + width: 40, }, headerInfo: { flex: 1, @@ -1349,10 +1438,10 @@ const styles = StyleSheet.create((theme, rt) => ({ liveIndicatorContainer: { alignItems: 'center', backgroundColor: theme.colors.black.uiActiveAlpha, - borderRadius: theme.radii.sm, + borderRadius: theme.radii.md, flexDirection: 'row', - paddingHorizontal: theme.spacing.xs, - paddingVertical: 2, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, }, loadingOverlay: { alignItems: 'center', @@ -1365,6 +1454,7 @@ const styles = StyleSheet.create((theme, rt) => ({ top: 0, }, overlayBackground: { + backgroundColor: theme.colors.black.bgAlpha, bottom: 0, left: 0, position: 'absolute', @@ -1374,10 +1464,10 @@ const styles = StyleSheet.create((theme, rt) => ({ playPauseButton: { alignItems: 'center', backgroundColor: theme.colors.black.uiActiveAlpha, - borderRadius: 40, - height: 80, + borderRadius: 44, + height: 88, justifyContent: 'center', - width: 80, + width: 88, }, spacer: { flex: 1, @@ -1402,11 +1492,11 @@ const styles = StyleSheet.create((theme, rt) => ({ }, streamerNameContainer: { backgroundColor: theme.colors.black.uiActiveAlpha, - borderRadius: theme.radii.sm, + borderRadius: theme.radii.md, flex: 1, - marginHorizontal: theme.spacing.xs, - paddingHorizontal: theme.spacing.xs, - paddingVertical: 2, + marginHorizontal: theme.spacing.sm, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, }, viewerCount: { alignItems: 'center', @@ -1416,10 +1506,10 @@ const styles = StyleSheet.create((theme, rt) => ({ viewerCountContainer: { alignItems: 'center', backgroundColor: theme.colors.black.uiActiveAlpha, - borderRadius: theme.radii.sm, + borderRadius: theme.radii.md, flexDirection: 'row', - paddingHorizontal: theme.spacing.xs, - paddingVertical: 2, + paddingHorizontal: theme.spacing.sm, + paddingVertical: theme.spacing.xs, }, viewerCountText: { color: theme.colors.gray.contrast, diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 628827d0..783420ee 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -235,8 +235,8 @@ export const AuthContextProvider = ({ try { const u = await twitchService.getUserInfo(twitchToken.accessToken); - setUser(u); twitchApi.setAuthToken(twitchToken.accessToken); + setUser(u); // Prefetch initial data immediately after auth prefetchInitialData(u.id); @@ -294,9 +294,9 @@ export const AuthContextProvider = ({ try { const u = await twitchService.getUserInfo(token.accessToken); - setUser(u); - + // Set token before setUser so any enabled queries (e.g. followed streams) use the correct token twitchApi.setAuthToken(token.accessToken); + setUser(u); prefetchInitialData(u.id); diff --git a/src/screens/FollowingScreen.tsx b/src/screens/FollowingScreen.tsx index 6184637b..6987ee80 100644 --- a/src/screens/FollowingScreen.tsx +++ b/src/screens/FollowingScreen.tsx @@ -11,7 +11,7 @@ import { useRefresh } from '@app/hooks/useRefresh'; import { twitchQueries } from '@app/queries/twitchQueries'; import { TwitchStream } from '@app/services/twitch-service'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo, useCallback, type JSX } from 'react'; +import { useMemo, useCallback, useRef, useEffect, type JSX } from 'react'; import { Platform, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet } from 'react-native-unistyles'; @@ -46,12 +46,25 @@ export default function FollowingScreen() { data: streams, isLoading, isError, + isFetched, } = useQuery({ ...followingStreamsQuery, enabled: !!user?.id, + retry: 2, + retryDelay: (attemptIndex: number) => + Math.min(1000 * 2 ** attemptIndex, 3000), + refetchOnMount: true, + refetchOnWindowFocus: true, }); const streamsArray = Array.isArray(streams) ? streams : []; + const hasShownErrorToast = useRef(false); + + useEffect(() => { + if (!isError) { + hasShownErrorToast.current = false; + } + }, [isError]); const renderItem: ListRenderItem = useCallback(({ item }) => { return ; @@ -67,9 +80,19 @@ export default function FollowingScreen() { ); } - if ((!isLoading && !streams) || isError) { - // Only show error toast if user is logged in (query was enabled) - if (user?.id) { + + if (!user?.id) { + return ( + + ); + } + + if (isFetched && isError) { + if (!hasShownErrorToast.current) { + hasShownErrorToast.current = true; toast.error('Failed to fetch followed streams'); } return ( @@ -80,6 +103,18 @@ export default function FollowingScreen() { ); } + // Query enabled but not yet fetched (shouldn't happen after isLoading check, but guard for no data) + if (!streams) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ); + } + if (streamsArray.length === 0) { return ( > = ({ const { data: stream, isPending: isStreamPending } = streamQueryResult; + // Render chat once stream + WebView are ready (or after max wait so chat syncs within ~5s) + const MAX_WAIT_FOR_WEBVIEW_MS = 4000; useEffect(() => { - if ( - stream?.user_login && - stream?.user_id && - webViewLoaded && - !shouldRenderChat - ) { - setShouldRenderChat(true); + if (!stream?.user_login || !stream?.user_id) return; + if (webViewLoaded) { + if (!shouldRenderChat) setShouldRenderChat(true); + return; } + const timeout = setTimeout(() => { + setWebViewLoaded(true); + setShouldRenderChat(true); + }, MAX_WAIT_FOR_WEBVIEW_MS); + return () => clearTimeout(timeout); }, [stream?.user_login, stream?.user_id, webViewLoaded, shouldRenderChat]); const getVideoDimensions = useCallback(() => {