Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
24 kB · 792 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793import { useNavigation } from "@react-navigation/native";import { Avatar, ContentWarningBadge, hexToRgba, PlayerStatus, PlayerUI, ShareSheet, Slider, Text, Toast, useAuthor, useAvatar, useCameraToggle, useLivestream, useLivestreamInfo, useLivestreamStore, useMuted, usePlayerDimensions, usePlayerStore, useRotation, useSegmentDimensions, useSetMuted, useSetVolume, useTheme, useVolume, View, zero,} from "@streamplace/components";import { colors, motion, scrims, statusColors, textAlphas,} from "@streamplace/components/src/lib/theme/tokens";import { px, py } from "@streamplace/components/src/ui";import { Image } from "expo-image";import useAvatars from "hooks/useAvatars";import { ChevronLeft, ChevronRight, Fullscreen, Maximize, Minimize, SwitchCamera, Volume2, VolumeX,} from "lucide-react-native";import { useEffect, useRef, useState } from "react";import { Platform, Pressable } from "react-native";import { Gesture, GestureDetector } from "react-native-gesture-handler";import Animated, { runOnJS, SharedValue, useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";import { SafeAreaView } from "react-native-safe-area-context";import { MobileChatPanel } from "./chat";import { useResponsiveLayout } from "./useResponsiveLayout";
const { borders, bottom, gap, h, layout, position, right, w, r } = zero;
export function MobileUi({ setShowChat, showChat, hideMobileChat, embed = false, sharedFadeOpacity,}: { setShowChat?: (show: boolean) => void; showChat?: boolean; hideMobileChat?: boolean; embed?: boolean; sharedFadeOpacity?: SharedValue<number>;}) { const { theme } = useTheme(); const navigation = useNavigation(); const { ingest, title, setTitle, showCountdown, setShowCountdown, recordSubmitted, setRecordSubmitted, toggleGoLive, toggleStopStream, profile: streamProfile, } = useLivestreamInfo(); const { width, height } = usePlayerDimensions(); const { isPlayerRatioGreater } = useSegmentDimensions(); const { doSetIngestCamera } = useCameraToggle(); const avis = useAvatars([streamProfile?.did].filter(Boolean) as string[]);
const mode = usePlayerStore((state) => state.mode); const muteWasForced = usePlayerStore((state) => state.muteWasForced); const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); const [playerIsReady, setPlayerIsReady] = useState(false); const playerStatusReady = usePlayerStore( (state) => state.status === PlayerStatus.PLAYING, ); useEffect(() => { if (playerIsReady) return; if (playerStatusReady) { setPlayerIsReady(true); } else { const handle = setTimeout(() => { setPlayerIsReady(true); }, 5000); return () => clearTimeout(handle); } }, [playerStatusReady]); const muted = useMuted(); const setMuted = useSetMuted(); const ls = useLivestream(); const segment = useLivestreamStore((x) => x.segment); const contentWarnings = (segment?.contentWarnings?.warnings as string[]) || [];
const { shouldShowFloatingMetrics, shouldShowChatSidePanel, chatPanelWidth } = useResponsiveLayout();
const [showLoading, setShowLoading] = useState(false);
const openChatOnlyMode = () => { const user = streamProfile?.handle; if (!user) return; navigation.navigate("PopoutChat" as any, { user }); };
useEffect(() => { if (recordSubmitted) setShowLoading(false); }, [recordSubmitted]);
const isPortrait = !shouldShowChatSidePanel; const isSelfAndNotLive = ingest !== null && ls === null; const isSelfAndLive = ingest !== null && ls !== null;
// Controls auto-hide after 3s of inactivity (design-system standard) const FADE_OUT_DELAY = 3000; const internalFadeOpacity = useSharedValue(1); const fadeOpacity = sharedFadeOpacity ?? internalFadeOpacity; const fadeTimeout = useRef<NodeJS.Timeout | null>(null); const selectedRendition = usePlayerStore((state) => state.selectedRendition);
const resetFadeTimer = () => { fadeOpacity.value = withTiming(1, { duration: motion.base }); if (fadeTimeout.current) clearTimeout(fadeTimeout.current); if (selectedRendition === "audio") return; if (ingest !== null) return; fadeTimeout.current = setTimeout(() => { fadeOpacity.value = withTiming(0, { duration: motion.slow }); }, FADE_OUT_DELAY); };
useEffect(() => { resetFadeTimer(); return () => { if (fadeTimeout.current) clearTimeout(fadeTimeout.current); }; }, []);
const showUI = () => { "worklet"; fadeOpacity.value = withTiming(1, { duration: motion.base }); };
const onPlayerHover = () => { "worklet"; showUI(); // Schedule timer reset on JS thread runOnJS(resetFadeTimer)(); };
const animatedFadeStyle = useAnimatedStyle(() => ({ opacity: fadeOpacity.value, transform: isPortrait ? [{ translateY: (fadeOpacity.value - 1) * 40 }] : [], }));
const hover = Gesture.Hover().onChange(onPlayerHover); const pan = Gesture.Pan().onChange(onPlayerHover); const tap = Gesture.Tap().onEnd(onPlayerHover); let chatSection: React.ReactNode = null; if ( mode !== "vod" && !hideMobileChat && !isSelfAndNotLive && playerIsReady && isPortrait ) { chatSection = ( <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} /> ); } const combined = Gesture.Race(hover, pan, tap); return ( <> <GestureDetector gesture={combined}> <View style={[layout.position.absolute, h.percent[100], w.percent[100]]} > <Animated.View style={[ layout.position.absolute, h.percent[100], w.percent[100], animatedFadeStyle, ]} > {/* Main UI Overlay */} <View style={[h.percent[100], w.percent[100]]}> {ingest === null ? ( <SafeAreaView // VOD's container already insets the video below the notch, so // the controls only need the small style padding; live fills // the window and clears the notch with the top edge inset. edges={mode === "vod" ? [] : ["top"]} style={[ layout.flex.row, layout.flex.alignCenter, py[2], px[2], gap.all[4], w.percent[100], ]} > <Pressable onPress={() => { navigation.canGoBack() ? navigation.goBack() : navigation.navigate("MainTabs" as any, { screen: "HomeTab", }); }} style={[ { padding: 9, backgroundColor: scrims.light, borderRadius: 12, }, r[2], ]} > <ChevronLeft color={colors.white} /> </Pressable> {/* if we're in landscape mode show the profile picture and username */} {shouldShowChatSidePanel && ( <View style={[ { backgroundColor: scrims.light, borderRadius: 12, }, r[2], layout.flex.row, layout.flex.alignCenter, gap.all[1], px[3], py[2], ]} > <Avatar src={ streamProfile?.did && avis[streamProfile?.did].avatar } name={streamProfile?.handle} size="sm" /> <Text numberOfLines={1} ellipsizeMode="tail" style={{ color: colors.white, fontSize: 14, marginLeft: 8, }} > {streamProfile?.handle} </Text> </View> )} <ContentWarningBadge warnings={contentWarnings} truncate /> <View style={{ flex: 1 }} /> <View style={[ { backgroundColor: scrims.light, borderRadius: 12, }, r[2], layout.flex.row, layout.flex.alignCenter, gap.all[4], px[3], py[2], ]} > <ShareSheet /> <PlayerUI.ContextMenu onOpenChat={ streamProfile?.handle ? openChatOnlyMode : undefined } /> {shouldShowChatSidePanel && setShowChat && ( <Pressable onPress={() => { setShowChat(!showChat); }} > {showChat ? ( <ChevronRight color={colors.white} size={20} /> ) : ( <ChevronLeft color={colors.white} size={20} /> )} </Pressable> )} </View> </SafeAreaView> ) : ( <SafeAreaView // VOD's container already insets the video below the notch, so // the controls only need the small style padding; live fills // the window and clears the notch with the top edge inset. edges={mode === "vod" ? [] : ["top"]} style={[ px[2], py[2], layout.flex.row, layout.flex.spaceBetween, w.percent[100], ]} > {/* Left Controls Column */} <View style={[ layout.flex.column, gap.all[2], { maxWidth: "70%" }, ]} > <LeftControlsPanel navigation={navigation} muted={muted} setMuted={setMuted} muteWasForced={muteWasForced} setMuteWasForced={setMuteWasForced} /> </View>
{/* Right Controls Column */} <View style={[ layout.flex.row, gap.all[2], layout.flex.align.start, ]} > <View> <View style={[ { padding: 9, backgroundColor: scrims.light, borderRadius: 12, }, r[2], ]} > <ShareSheet /> </View> </View>
<RightControlsPanel ingest={ingest} doSetIngestCamera={doSetIngestCamera} shouldShowChatSidePanel={shouldShowChatSidePanel} isPortrait={!shouldShowChatSidePanel} showChat={showChat} setShowChat={setShowChat} /> </View> </SafeAreaView> )}
{shouldShowFloatingMetrics && isSelfAndLive && ( <View style={[ layout.position.absolute, position.top[32], position.left[0], position.right[0], layout.flex.column, layout.flex.center, ]} > <PlayerUI.MetricsPanel showMetrics={shouldShowFloatingMetrics} /> </View> )} {Platform.OS !== "web" && ( <View style={[layout.position.absolute, { bottom: 12, right: 12 }]} > <RotateButton /> </View> )} </View> {isSelfAndNotLive && ( <PlayerUI.InputPanel title={title} setTitle={setTitle} toggleGoLive={toggleGoLive} isLive={isSelfAndLive} /> )}
<PlayerUI.CountdownOverlay visible={showCountdown} width={width} height={height - 150} onDone={() => { if (!recordSubmitted && title != "") { setShowLoading(true); } setShowCountdown(false); }} /> <PlayerUI.LoadingOverlay visible={showLoading} width={width} height={height - 150} subtitle="We're setting up your stream." />
<Toast open={recordSubmitted} onOpenChange={setRecordSubmitted} title="You're live!" description="We're notifying your followers that you just went live." duration={5} /> </Animated.View> <PlayerUI.AutoplayButton /> </View> </GestureDetector> {/* VOD scrub/play controls live OUTSIDE the gesture detector so the seek bar's own pan gesture isn't swallowed by the overlay's tap/pan Race. They still fade with the rest of the UI via the shared opacity. */} {mode === "vod" && ( <Animated.View style={[ { position: "absolute", bottom: 8, left: 0, right: 0 }, animatedFadeStyle, ]} // Let taps on empty space fall through to the overlay's tap-to-reveal // gesture; only the controls themselves capture touches. pointerEvents="box-none" > <PlayerUI.SeekBar /> <PlayerUI.VodControls /> </Animated.View> )} {chatSection} </> );}
function LeftControlsPanel({ navigation, muted, setMuted, muteWasForced, setMuteWasForced,}: { navigation: any; muted: boolean; setMuted: (muted: boolean) => void; muteWasForced: boolean; setMuteWasForced: (forced: boolean) => void;}) { const profile = useAuthor(); const avatar = useAvatar(); // Get content warnings from segment const segment = useLivestreamStore((x) => x.segment); const contentWarnings = (segment?.contentWarnings?.warnings as string[]) || [];
return ( <> {/* Back Button and Profile */} <View style={[ { padding: 3, paddingRight: 8, backgroundColor: scrims.light, borderRadius: 12, alignSelf: "flex-start", }, r[2], ]} > <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> <Pressable onPress={() => { navigation.canGoBack() ? navigation.goBack() : navigation.navigate("MainTabs" as any, { screen: "HomeTab" }); }} > <ChevronLeft color={colors.white} /> </Pressable> <Image source={ avatar ? { uri: avatar } : require("assets/images/goose.png") } key={profile?.did} style={[ { width: 36, height: 36, }, { borderRadius: 999 }, borders.width.thin, borders.color.gray[700], ]} /> <Text numberOfLines={1} ellipsizeMode="tail"> {profile?.handle} </Text> </View> </View>
{/* Muted indicator */} {muted && ( <Pressable onPress={() => { if (muteWasForced) { setMuted(false); setMuteWasForced(false); } else { setMuted(false); } }} style={[ { flexDirection: "row", alignItems: "center", gap: 8, }, ]} > <View style={[ { padding: 4, backgroundColor: scrims.light, borderRadius: 999, borderWidth: 2, borderColor: hexToRgba(statusColors.dark.danger, 0.2), }, ]} > <VolumeX size="24" color={hexToRgba(statusColors.dark.danger, 0.8)} /> </View> <Text color="muted" size="sm"> Tap to unmute </Text> </Pressable> )} <View> <ContentWarningBadge warnings={contentWarnings} truncate /> </View> </> );}
function RightControlsPanel({ ingest, doSetIngestCamera, shouldShowChatSidePanel, isPortrait, showChat, setShowChat,}: { ingest: string | null; doSetIngestCamera: () => void; shouldShowChatSidePanel: boolean; isPortrait: boolean; showChat?: boolean; setShowChat?: (show: boolean) => void;}) { const { theme } = useTheme(); const volume = useVolume(); const setVolume = useSetVolume(); const muted = useMuted(); const setMuted = useSetMuted(); const fullscreen = usePlayerStore((x) => x.fullscreen); const setFullscreen = usePlayerStore((x) => x.setFullscreen);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const handleVolumePress = () => { setShowVolumeSlider(!showVolumeSlider); };
const handleVolumeChange = (values: number[]) => { const newVolume = values[0] / 100; // Convert from 0-100 to 0-1 setVolume(newVolume); if (newVolume === 0) { setMuted(true); } else { setMuted(false); } };
const sliderValue = (muted ? 0 : volume) * 100;
return ( <View style={[ zero.layout.flex.column, zero.gap.all[2], zero.layout.flex.align.end, ]} > <View style={[ { backgroundColor: scrims.light, borderRadius: 12, paddingVertical: 2.25 * 4, }, zero.r[2], isPortrait ? zero.layout.flex.column : zero.layout.flex.row, zero.layout.flex.center, zero.gap.all[4], isPortrait ? zero.px[2] : zero.px[3], zero.layout.position.relative, ]} > {ingest === null ? ( Platform.OS === "web" && <PlayerUI.ContextMenu /> ) : ( <> <Pressable onPress={doSetIngestCamera}> <SwitchCamera color={theme.colors.foreground} size={20} /> </Pressable> {Platform.OS === "web" && <PlayerUI.StreamContextMenu />} </> )} {Platform.OS === "web" ? ( <> <Pressable onPress={() => { setFullscreen(!fullscreen); }} style={[zero.p[2], r[1]]} > {fullscreen ? ( <Minimize color={theme.colors.text} size={20} /> ) : ( <Fullscreen color={theme.colors.text} size={20} /> )} </Pressable> <Pressable onPress={handleVolumePress}> {muted || volume === 0 ? ( <VolumeX color={theme.colors.foreground} size={20} /> ) : ( <Volume2 color={theme.colors.foreground} size={20} /> )} </Pressable> </> ) : ingest === null ? ( <PlayerUI.ContextMenu /> ) : ( <PlayerUI.StreamContextMenu /> )} {shouldShowChatSidePanel && setShowChat && ( <Pressable onPress={() => { setShowChat(!showChat); }} > {showChat ? ( <ChevronRight color={colors.white} size={20} /> ) : ( <ChevronLeft color={colors.white} size={20} /> )} </Pressable> )} </View> {/* Volume Slider Popup */} {showVolumeSlider && ( <View style={[ { padding: 10, backgroundColor: scrims.dark, borderRadius: 12, width: 150, height: 36, bottom: -36 - 10, }, zero.r[2], zero.layout.position.absolute, ]} onTouchStart={(e) => e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} > <Slider.Root style={{ position: "relative", display: "flex", alignItems: "center", width: "100%", height: 12, }} value={sliderValue} min={0} max={100} onValueChange={handleVolumeChange} > <Slider.Track style={{ position: "absolute", width: "100%", height: 3, backgroundColor: textAlphas.dark[4], borderRadius: 999, top: "50%", transform: [{ translateY: -1.5 }], }} > <Slider.Range style={{ position: "absolute", backgroundColor: colors.white, borderRadius: 999, height: 3, top: 0, }} /> <Slider.Thumb style={{ position: "absolute", width: 16, height: 16, borderRadius: 8, backgroundColor: colors.white, top: -6.5, transform: [{ translateX: -8 }], }} /> </Slider.Track> </Slider.Root> </View> )} </View> );}
/** Native fullscreen/rotation toggle, pinned to the player's bottom-right. */function RotateButton() { const { theme } = useTheme(); const { toggleRotation, canRotate, currentOrientation } = useRotation(); if (!canRotate) return null; return ( <View style={[ { padding: 9, backgroundColor: scrims.light, borderRadius: 12 }, r[2], ]} > <Pressable onPress={toggleRotation}> {currentOrientation === 1 ? ( <Maximize color={theme.colors.foreground} size={20} /> ) : ( <Minimize color={theme.colors.foreground} size={20} /> )} </Pressable> </View> );}