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 <[email protected]>
This commit was merged in pull request #219.
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
Headphones,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react-native";
|
||||
import type { Human, Particle } from "@/api/types";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useOpenHuddle } from "@/features/huddle/use-open-huddle";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamTopActionsProps {
|
||||
@@ -36,6 +43,9 @@ export function StreamTopActions({
|
||||
showFitToggle,
|
||||
}: StreamTopActionsProps) {
|
||||
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 memberIds =
|
||||
visibility.mode === "network"
|
||||
@@ -79,6 +89,37 @@ export function StreamTopActions({
|
||||
) : null}
|
||||
</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 ? (
|
||||
<Pressable
|
||||
onPress={onToggleVideoFit}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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 { StatusBar } from "expo-status-bar";
|
||||
import * as Haptics from "expo-haptics";
|
||||
@@ -117,6 +118,13 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const [holdActive, setHoldActive] = useState(false);
|
||||
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.
|
||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||
|
||||
@@ -385,6 +393,8 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
|
||||
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
||||
|
||||
Reference in New Issue
Block a user