feat(mobile): add support for huddles to mobile app (#219)
* feat(mobile): add huddles with LiveKit Mirrors the desktop huddle window: tap the headphones button on a stream to fetch a LiveKit token from Orion and join a video room. Active huddles show a red badge on the stream card (participant count) and a red Join pill in the stream top bar — both driven by huddle_active_participants synced from the LiveKit webhook. The HuddleScreen lives at its own route (not a modal/window): video tile grid with placeholders for camera-off, mic/camera toggles, and a leave button. Adds the LiveKit RN SDK plus the WebRTC + LiveKit Expo config plugins for prebuild. https://claude.ai/code/session_01Po5tAD17MXhnqGQ9hKh6PC * fix(mobile): exiting from huddle * fix: pause stream during huddle --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #219.
This commit is contained in:
@@ -57,6 +57,11 @@ const config: ExpoConfig = {
|
|||||||
color: "#000000",
|
color: "#000000",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// LiveKit needs WebRTC native modules. The two plugins below patch the
|
||||||
|
// iOS/Android prebuild so camera/mic permissions and the webrtc pod are
|
||||||
|
// wired up correctly for huddles.
|
||||||
|
"@config-plugins/react-native-webrtc",
|
||||||
|
"@livekit/react-native-expo-plugin",
|
||||||
],
|
],
|
||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: false,
|
typedRoutes: false,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
import { registerGlobals } from "@livekit/react-native";
|
||||||
import { registerRootComponent } from "expo";
|
import { registerRootComponent } from "expo";
|
||||||
import App from "./src/App";
|
import App from "./src/App";
|
||||||
|
|
||||||
|
// LiveKit pipes WebRTC globals (RTCPeerConnection, mediaDevices, ...) onto the
|
||||||
|
// JS global. Must run before any LiveKit client is constructed, so do it at
|
||||||
|
// app entry rather than inside the huddle screen.
|
||||||
|
registerGlobals();
|
||||||
|
|
||||||
registerRootComponent(App);
|
registerRootComponent(App);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start -c",
|
||||||
"ios": "expo run:ios --device",
|
"ios": "expo run:ios --device",
|
||||||
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
|
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
@@ -30,6 +30,11 @@
|
|||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-video": "~3.0.10",
|
"expo-video": "~3.0.10",
|
||||||
"firebase": "^12.10.0",
|
"firebase": "^12.10.0",
|
||||||
|
"@config-plugins/react-native-webrtc": "^12.0.0",
|
||||||
|
"@livekit/react-native": "^2.7.5",
|
||||||
|
"@livekit/react-native-expo-plugin": "^1.0.2",
|
||||||
|
"@livekit/react-native-webrtc": "^144.1.0",
|
||||||
|
"livekit-client": "^2.15.2",
|
||||||
"lucide-react-native": "^0.575.0",
|
"lucide-react-native": "^0.575.0",
|
||||||
"nativewind": "^4.1.23",
|
"nativewind": "^4.1.23",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import type { Human } from "@/api/types";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import {
|
||||||
|
AudioSession,
|
||||||
|
LiveKitRoom,
|
||||||
|
VideoTrack,
|
||||||
|
isTrackReference,
|
||||||
|
useLocalParticipant,
|
||||||
|
useRoomContext,
|
||||||
|
useTracks,
|
||||||
|
} from "@livekit/react-native";
|
||||||
|
import type { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
|
import { Track } from "livekit-client";
|
||||||
|
import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native";
|
||||||
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera
|
||||||
|
* controls, and a leave button. Mirrors the desktop HuddleApp but tuned for
|
||||||
|
* touch: bigger controls, grid layout instead of focus/carousel, no chat or
|
||||||
|
* screenshare (camera + audio only).
|
||||||
|
*/
|
||||||
|
export function HuddleScreen({
|
||||||
|
route,
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<"Huddle">) {
|
||||||
|
const { token, serverUrl, streamName, networkId } = route.params;
|
||||||
|
|
||||||
|
// iOS in particular requires us to bracket the room session with
|
||||||
|
// start/stop calls so the AVAudioSession is configured for VoIP routing
|
||||||
|
// (earpiece → speaker, ducking, etc.). Without this, remote audio is
|
||||||
|
// routed to the receiver speaker and the user has to hold the phone to
|
||||||
|
// their ear to hear anyone.
|
||||||
|
useEffect(() => {
|
||||||
|
void AudioSession.startAudioSession().catch(() => {
|
||||||
|
// Non-fatal: room will still connect, audio routing may just be
|
||||||
|
// less ideal. Failing the whole screen here would feel worse.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
void AudioSession.stopAudioSession();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leave = useCallback(() => {
|
||||||
|
navigation.goBack();
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-black">
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<LiveKitRoom
|
||||||
|
token={token}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
connect={true}
|
||||||
|
audio={true}
|
||||||
|
video={false}
|
||||||
|
options={{ adaptiveStream: { pixelDensity: "screen" } }}
|
||||||
|
onDisconnected={leave}
|
||||||
|
onError={(err) => {
|
||||||
|
Alert.alert("Huddle error", err.message ?? "Failed to connect.");
|
||||||
|
leave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HuddleRoom
|
||||||
|
networkId={networkId}
|
||||||
|
streamName={streamName}
|
||||||
|
onLeave={leave}
|
||||||
|
/>
|
||||||
|
</LiveKitRoom>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HuddleRoomProps {
|
||||||
|
networkId: string;
|
||||||
|
streamName: string;
|
||||||
|
onLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
||||||
|
const room = useRoomContext();
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const { localParticipant, isMicrophoneEnabled, isCameraEnabled } =
|
||||||
|
useLocalParticipant();
|
||||||
|
|
||||||
|
// `withPlaceholder: true` ensures we get a tile for every participant even
|
||||||
|
// when their camera is off — same pattern as desktop's HuddleContent.
|
||||||
|
const tracks = useTracks(
|
||||||
|
[{ source: Track.Source.Camera, withPlaceholder: true }],
|
||||||
|
{ onlySubscribed: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMic = useCallback(() => {
|
||||||
|
void localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled);
|
||||||
|
}, [localParticipant, isMicrophoneEnabled]);
|
||||||
|
|
||||||
|
const toggleCamera = useCallback(() => {
|
||||||
|
void localParticipant.setCameraEnabled(!isCameraEnabled);
|
||||||
|
}, [localParticipant, isCameraEnabled]);
|
||||||
|
|
||||||
|
const leave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await room.disconnect();
|
||||||
|
} finally {
|
||||||
|
onLeave();
|
||||||
|
}
|
||||||
|
}, [room, onLeave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
|
||||||
|
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-white text-base font-semibold" numberOfLines={1}>
|
||||||
|
{streamName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white/60 text-xs mt-0.5">
|
||||||
|
{tracks.length === 1
|
||||||
|
? "1 participant"
|
||||||
|
: `${tracks.length} participants`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 px-2">
|
||||||
|
<TileGrid tiles={tracks} humans={network?.humans ?? []} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-center gap-4 px-4 py-4">
|
||||||
|
<ControlButton
|
||||||
|
label={isMicrophoneEnabled ? "Mute" : "Unmute"}
|
||||||
|
active={isMicrophoneEnabled}
|
||||||
|
onPress={toggleMic}
|
||||||
|
icon={
|
||||||
|
isMicrophoneEnabled ? (
|
||||||
|
<Mic color="white" size={22} />
|
||||||
|
) : (
|
||||||
|
<MicOff color="white" size={22} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label={isCameraEnabled ? "Stop video" : "Start video"}
|
||||||
|
active={isCameraEnabled}
|
||||||
|
onPress={toggleCamera}
|
||||||
|
icon={
|
||||||
|
isCameraEnabled ? (
|
||||||
|
<Video color="white" size={22} />
|
||||||
|
) : (
|
||||||
|
<VideoOff color="white" size={22} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Leave"
|
||||||
|
tone="danger"
|
||||||
|
onPress={() => void leave()}
|
||||||
|
icon={<PhoneOff color="white" size={22} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TileGridProps {
|
||||||
|
tiles: TrackReferenceOrPlaceholder[];
|
||||||
|
humans: Human[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TileGrid({ tiles, humans }: TileGridProps) {
|
||||||
|
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
|
||||||
|
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
|
||||||
|
const columns = tiles.length <= 1 ? 1 : 2;
|
||||||
|
const { width, height } = Dimensions.get("window");
|
||||||
|
const rows = Math.max(1, Math.ceil(tiles.length / columns));
|
||||||
|
const tileWidth = (width - 16) / columns - 8;
|
||||||
|
// Subtract approx chrome height (header + control bar ≈ 200px). This is a
|
||||||
|
// simple heuristic — we don't need pixel-perfect since tiles use aspectRatio.
|
||||||
|
const availableHeight = height - 220;
|
||||||
|
const tileHeight = Math.max(140, availableHeight / rows - 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 flex-row flex-wrap items-start justify-center">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<View
|
||||||
|
key={trackKey(tile)}
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
margin: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tile tile={tile} humans={humans} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
tile,
|
||||||
|
humans,
|
||||||
|
}: {
|
||||||
|
tile: TrackReferenceOrPlaceholder;
|
||||||
|
humans: Human[];
|
||||||
|
}) {
|
||||||
|
const identity = tile.participant.identity;
|
||||||
|
const display = resolveHumanDisplay(identity, humans);
|
||||||
|
const name = tile.participant.name || display.displayName;
|
||||||
|
const initials = display.initials;
|
||||||
|
const isSpeaking = tile.participant.isSpeaking;
|
||||||
|
const muted =
|
||||||
|
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
||||||
|
true;
|
||||||
|
|
||||||
|
const hasVideo = isTrackReference(tile) && !tile.publication.isMuted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-hidden rounded-2xl bg-neutral-900",
|
||||||
|
isSpeaking && "border-2 border-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasVideo ? (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={tile}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
||||||
|
<Text className="text-white text-xl font-semibold">{initials}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
|
||||||
|
{muted ? (
|
||||||
|
<MicOff color="white" size={12} />
|
||||||
|
) : (
|
||||||
|
<Mic color="#34d399" size={12} />
|
||||||
|
)}
|
||||||
|
<Text className="text-white text-xs" numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackKey(tile: TrackReferenceOrPlaceholder): string {
|
||||||
|
const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder";
|
||||||
|
return `${tile.participant.identity}:${tile.source}:${sid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlButtonProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
active = false,
|
||||||
|
tone = "default",
|
||||||
|
}: ControlButtonProps) {
|
||||||
|
// Used purely for the visual state — destructive tone always wins so
|
||||||
|
// "Leave" is unmistakable regardless of toggle state.
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
className={cn(
|
||||||
|
"h-14 w-14 items-center justify-center rounded-full",
|
||||||
|
tone === "danger"
|
||||||
|
? "bg-red-600 active:bg-red-700"
|
||||||
|
: active
|
||||||
|
? "bg-white/20 active:bg-white/30"
|
||||||
|
: "bg-white/10 active:bg-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
|
import type { RootStackParamList } from "@/navigation/types";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh
|
||||||
|
* LiveKit token from Orion for the {network, stream} pair and hand off to
|
||||||
|
* the dedicated huddle screen. Joining and starting are the same operation
|
||||||
|
* server-side: minting a token implicitly creates/joins the room.
|
||||||
|
*/
|
||||||
|
export function useOpenHuddle() {
|
||||||
|
const navigation =
|
||||||
|
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
async (networkId: string, streamId: string, streamName: string) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { token, server_url } = await apiClient.getLivekitToken(
|
||||||
|
networkId,
|
||||||
|
streamId,
|
||||||
|
);
|
||||||
|
navigation.navigate("Huddle", {
|
||||||
|
networkId,
|
||||||
|
streamId,
|
||||||
|
streamName,
|
||||||
|
token,
|
||||||
|
serverUrl: server_url,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigation, loading],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { open, loading };
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||||
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
|
import {
|
||||||
|
EllipsisVertical,
|
||||||
|
Globe,
|
||||||
|
Headphones,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
} from "lucide-react-native";
|
||||||
import type { Human, Particle } from "@/api/types";
|
import type { Human, Particle } from "@/api/types";
|
||||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/Avatar";
|
import { Avatar } from "@/components/Avatar";
|
||||||
|
import { useOpenHuddle } from "@/features/huddle/use-open-huddle";
|
||||||
import { useStreamPresence } from "./stream-presence-context";
|
import { useStreamPresence } from "./stream-presence-context";
|
||||||
|
|
||||||
interface StreamTopActionsProps {
|
interface StreamTopActionsProps {
|
||||||
@@ -36,6 +43,9 @@ export function StreamTopActions({
|
|||||||
showFitToggle,
|
showFitToggle,
|
||||||
}: StreamTopActionsProps) {
|
}: StreamTopActionsProps) {
|
||||||
const { onlineHumanIds } = useStreamPresence();
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
|
const { open: openHuddle, loading: huddleLoading } = useOpenHuddle();
|
||||||
|
const huddleCount = streamParticle.huddle_active_participants?.length ?? 0;
|
||||||
|
const huddleActive = huddleCount > 0;
|
||||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||||
const memberIds =
|
const memberIds =
|
||||||
visibility.mode === "network"
|
visibility.mode === "network"
|
||||||
@@ -79,6 +89,37 @@ export function StreamTopActions({
|
|||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() =>
|
||||||
|
void openHuddle(
|
||||||
|
networkId,
|
||||||
|
streamParticle.id,
|
||||||
|
streamParticle.properties.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={huddleLoading}
|
||||||
|
accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"}
|
||||||
|
className={cn(
|
||||||
|
"h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1",
|
||||||
|
huddleActive
|
||||||
|
? "bg-red-500/90 active:bg-red-600"
|
||||||
|
: "bg-white/10 active:bg-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{huddleLoading ? (
|
||||||
|
<ActivityIndicator color="white" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Headphones color="white" size={14} strokeWidth={2} />
|
||||||
|
{huddleActive ? (
|
||||||
|
<Text className="text-white text-xs font-semibold">
|
||||||
|
{huddleCount}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
{showFitToggle ? (
|
{showFitToggle ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onToggleVideoFit}
|
onPress={onToggleVideoFit}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
||||||
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
@@ -117,6 +118,13 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const [holdActive, setHoldActive] = useState(false);
|
const [holdActive, setHoldActive] = useState(false);
|
||||||
useSuspendPlayback(holdActive, "touch-hold");
|
useSuspendPlayback(holdActive, "touch-hold");
|
||||||
|
|
||||||
|
// Suspend playback whenever another screen (Huddle, NewStream, modals
|
||||||
|
// routed as screens) is on top. Native stack keeps StreamView mounted, so
|
||||||
|
// without this the stream would keep advancing — and the exit countdown
|
||||||
|
// would fire — behind the huddle.
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
useSuspendPlayback(!isFocused, "screen-unfocused");
|
||||||
|
|
||||||
// Reaction sheet — opens via swipe-up on the canvas.
|
// Reaction sheet — opens via swipe-up on the canvas.
|
||||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -385,6 +393,8 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- End-of-stream countdown ---
|
// --- End-of-stream countdown ---
|
||||||
|
// `paused` already reflects unfocused state via the "screen-unfocused"
|
||||||
|
// suspender registered above, so the countdown freezes under the huddle.
|
||||||
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
||||||
|
|
||||||
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { Headphones } from "lucide-react-native";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
import { isParticleDeleted } from "@/api/types";
|
import { isParticleDeleted } from "@/api/types";
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||||
@@ -147,7 +148,14 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isUnseen ? (
|
{(particle.huddle_active_participants?.length ?? 0) > 0 ? (
|
||||||
|
<View className="flex-row items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||||
|
<Headphones color="#ef4444" size={11} />
|
||||||
|
<Text className="text-red-500 text-[10px] font-semibold">
|
||||||
|
{particle.huddle_active_participants?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isUnseen ? (
|
||||||
<View className="bg-primary h-2 w-2 rounded-full" />
|
<View className="bg-primary h-2 w-2 rounded-full" />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
|
|||||||
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
||||||
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
|
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
|
||||||
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
||||||
|
import { HuddleScreen } from "@/features/huddle/HuddleScreen";
|
||||||
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
||||||
import { AccountScreen } from "@/features/settings/AccountScreen";
|
import { AccountScreen } from "@/features/settings/AccountScreen";
|
||||||
import type { RootStackParamList } from "./types";
|
import type { RootStackParamList } from "./types";
|
||||||
@@ -43,6 +44,11 @@ export function RootNavigator() {
|
|||||||
component={StreamViewScreen}
|
component={StreamViewScreen}
|
||||||
options={{ animation: "fade", gestureEnabled: false }}
|
options={{ animation: "fade", gestureEnabled: false }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Huddle"
|
||||||
|
component={HuddleScreen}
|
||||||
|
options={{ animation: "slide_from_bottom", gestureEnabled: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="NewStream"
|
name="NewStream"
|
||||||
component={NewStreamScreen}
|
component={NewStreamScreen}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export type RootStackParamList = {
|
|||||||
NetworkList: undefined;
|
NetworkList: undefined;
|
||||||
StreamList: { networkId: string };
|
StreamList: { networkId: string };
|
||||||
StreamView: { networkId: string; streamId: string };
|
StreamView: { networkId: string; streamId: string };
|
||||||
|
Huddle: {
|
||||||
|
networkId: string;
|
||||||
|
streamId: string;
|
||||||
|
streamName: string;
|
||||||
|
token: string;
|
||||||
|
serverUrl: string;
|
||||||
|
};
|
||||||
NewStream: { networkId: string };
|
NewStream: { networkId: string };
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
|
|||||||
+869
-12
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user