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(() => {