From dd359f61556bcf07e672a7abed71fb85626f2a2b Mon Sep 17 00:00:00 2001 From: natalie <22222885+espeon@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:14:43 -0400 Subject: [PATCH] dash: widgets-based dashboard (#450) * new live dashboard controls * widgets :) * remove padding of top bar * accurate data in the panel * bento updates + more accurate data * delete unused files and add thumb backup for video player * add options while not streaming * Switch stream selection over to css framework * Remove unused `useNewItemsPer` hook in chat panel * redo live dashboard UI w color and style updates * Replace Mod Actions with Information Widget in Bento Grid * move to components dir * add livestream panel back in * improved state management in livestream panel * Rewrite InformationWidget component and remove unused props * remove unneeded props * Update InformationWidget layout responsiveness * misc style and layout improvements --- .../components/live-dashboard/bento-grid.tsx | 244 + .../live-dashboard/live-selector.tsx | 92 +- .../live-dashboard/livestream-panel.tsx | 534 + js/app/components/live-dashboard/problems.tsx | 107 - .../components/live-dashboard/stream-key.tsx | 183 +- .../live-dashboard/stream-monitor.tsx | 213 + js/app/components/live-dashboard/waiting.tsx | 11 - js/app/components/ui/button-selector.tsx | 75 +- js/app/src/router.tsx | 12 + js/app/src/screens/info-widget-embed.tsx | 28 + js/app/src/screens/live-dashboard.tsx | 253 +- js/components/src/assets/emoji-data.json | 19371 ++++++++++++++++ .../src/components/chat/chat-message.tsx | 36 +- js/components/src/components/chat/chat.tsx | 27 +- .../src/components/dashboard/chat-panel.tsx | 80 + .../src/components/dashboard/header.tsx | 170 + .../src/components/dashboard/index.tsx | 5 + .../dashboard/information-widget.tsx | 526 + .../src/components/dashboard/mod-actions.tsx | 133 + .../src/components/dashboard/problems.tsx | 151 + js/components/src/components/ui/button.tsx | 4 +- js/components/src/components/ui/index.ts | 2 + js/components/src/components/ui/info-box.tsx | 60 + js/components/src/components/ui/info-row.tsx | 48 + js/components/src/components/ui/toast.tsx | 110 + js/components/src/index.tsx | 3 + js/components/src/lib/theme/atoms.ts | 140 +- js/components/src/lib/theme/tokens.ts | 297 +- 28 files changed, 22326 insertions(+), 589 deletions(-) create mode 100644 js/app/components/live-dashboard/bento-grid.tsx create mode 100644 js/app/components/live-dashboard/livestream-panel.tsx delete mode 100644 js/app/components/live-dashboard/problems.tsx create mode 100644 js/app/components/live-dashboard/stream-monitor.tsx delete mode 100644 js/app/components/live-dashboard/waiting.tsx create mode 100644 js/app/src/screens/info-widget-embed.tsx create mode 100644 js/components/src/assets/emoji-data.json create mode 100644 js/components/src/components/dashboard/chat-panel.tsx create mode 100644 js/components/src/components/dashboard/header.tsx create mode 100644 js/components/src/components/dashboard/index.tsx create mode 100644 js/components/src/components/dashboard/information-widget.tsx create mode 100644 js/components/src/components/dashboard/mod-actions.tsx create mode 100644 js/components/src/components/dashboard/problems.tsx create mode 100644 js/components/src/components/ui/info-box.tsx create mode 100644 js/components/src/components/ui/info-row.tsx diff --git a/js/app/components/live-dashboard/bento-grid.tsx b/js/app/components/live-dashboard/bento-grid.tsx new file mode 100644 index 00000000..d7c56656 --- /dev/null +++ b/js/app/components/live-dashboard/bento-grid.tsx @@ -0,0 +1,244 @@ +import { + Dashboard, + useLivestreamStore, + usePlayerStore, + useProfile, + useSegment, + useSegmentTiming, + zero, +} from "@streamplace/components"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dimensions, Platform, ScrollView, View } from "react-native"; +import { useLiveUser } from "../../hooks/useLiveUser"; +import LivestreamPanel from "./livestream-panel"; +import StreamMonitor from "./stream-monitor"; + +const { flex, p, gap, layout, bg } = zero; + +interface BentoGridProps { + userProfile: any; + isLive: boolean; + videoRef: any; +} + +export default function BentoGrid({ + userProfile, + isLive, + videoRef, +}: BentoGridProps) { + const isWeb = Platform.OS === "web"; + + // Screen width state for responsive design + const [screenWidth, setScreenWidth] = useState( + isWeb ? window.innerWidth : Dimensions.get("window").width, + ); + + useEffect(() => { + if (isWeb) { + const handleResize = () => { + setScreenWidth(window.innerWidth); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + } else { + const subscription = Dimensions.addEventListener( + "change", + ({ window }) => { + setScreenWidth(window.width); + }, + ); + return () => subscription?.remove(); + } + }, [isWeb]); + + const isDesktop = screenWidth >= 1200; + + // Get data from hooks for Dashboard components + const profile = useProfile(); + const viewers = useLivestreamStore((x) => x.viewers); + const chat = useLivestreamStore((x) => x.chat); + const segmentTiming = useSegmentTiming(); + const seg = useSegment(); + const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); + const ingestStarted = usePlayerStore((x) => x.ingestStarted); + const userIsLive = useLiveUser(); + + // Calculate derived values + const isConnected = ingestConnectionState === "connected"; + const canModerate = isLive && isConnected; + + // Calculate uptime + const getUptime = useCallback((): string => { + if (!ingestStarted || !isLive) return "00:00:00"; + const uptimeMs = Date.now() - ingestStarted; + const seconds = Math.floor(uptimeMs / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + }, [ingestStarted, isLive]); + + // Calculate bitrate + const getBitrate = useCallback((): string => { + if (!seg?.size || !seg?.duration) return "0 kbps"; + const kbps = + (seg.size * 8) / + ((seg.duration || 1000000000) / 1000000000) / + 1000 / + 1000; + return `${kbps.toFixed(2)} mbps`; + }, [seg?.size, seg?.duration]); + + // Map connection quality to status + const getConnectionStatus = useMemo((): + | "excellent" + | "good" + | "poor" + | "offline" => { + if (!isLive) return "offline"; + switch (segmentTiming.connectionQuality) { + case "good": + return "excellent"; + case "degraded": + return "good"; + case "poor": + return "poor"; + default: + return "offline"; + } + }, [isLive, segmentTiming.connectionQuality]); + + // Calculate messages per minute + const messagesPerMinute = useMemo((): number => { + const now = Date.now(); + const oneMinuteAgo = now - 60 * 1000; + return ( + chat?.filter( + (msg) => + typeof msg.timestamp === "number" && msg.timestamp > oneMinuteAgo, + )?.length || 0 + ); + }, [chat]); + + if (isDesktop) { + // Desktop layout (>= 1200px) - Original bento grid + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + return ( + + {/* Header always at top */} + + + + + {/* Vertical scrolling content */} + + {/* Stream Monitor Panel */} + + + + + {/* Chat Panel */} + + + + + {/* Livestream Panel */} + + + + + + ); +} diff --git a/js/app/components/live-dashboard/live-selector.tsx b/js/app/components/live-dashboard/live-selector.tsx index 0e154808..fd8cbf32 100644 --- a/js/app/components/live-dashboard/live-selector.tsx +++ b/js/app/components/live-dashboard/live-selector.tsx @@ -1,66 +1,88 @@ +import { useNavigation } from "@react-navigation/native"; +import { Button, Text, View, zero } from "@streamplace/components"; +import { flex } from "@streamplace/components/src/ui"; import { Camera, FerrisWheel } from "@tamagui/lucide-icons"; -import AQLink, { Redirect } from "components/aqlink"; +import { Redirect } from "components/aqlink"; import Loading from "components/loading/loading"; import { selectIsReady, selectUserProfile, } from "features/bluesky/blueskySlice"; -import React from "react"; +import React, { useState } from "react"; import { useAppSelector } from "store/hooks"; -import { H6, Text, View } from "tamagui"; +import { StreamKeyScreen } from "./stream-key"; + +const { layout, gap } = zero; + const elems = [ { - title: "Stream your camera!", + title: "Stream your camera", Icon: Camera, - to: "Webcam", + key: "webcam", }, { - title: "Stream from OBS!", + title: "Stream from OBS", Icon: FerrisWheel, - to: "StreamKey", + key: "streamkey", }, ]; export default function StreamScreen({ route }) { + const [selectedMode, setSelectedMode] = useState(null); const isReady = useAppSelector(selectIsReady); const userProfile = useAppSelector(selectUserProfile); + const navigation = useNavigation(); + if (!isReady) { return ; } if (!userProfile) { return ; } + + if (selectedMode === "webcam") { + navigation.navigate("MobileGoLive"); + } + + if (selectedMode === "streamkey") { + return ( + + + + + Stream from OBS + + + + + + ); + } + return ( - - - {elems.map(({ Icon, title, to }, i) => ( + + + {elems.map(({ Icon, title, key }, i) => ( - setSelectedMode(key)} + variant="primary" + size="xl" + style={[{ flexGrow: 0 }, layout.flex.column]} + leftIcon={} > - - - - - - {title} - - - - {i < elems.length - 1 && ( - -
OR
-
- )} + {title} +
))}
diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx new file mode 100644 index 00000000..bf5fca88 --- /dev/null +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -0,0 +1,534 @@ +import { + useCreateStreamRecord, + useLivestream, + useUpdateStreamRecord, + zero, +} from "@streamplace/components"; +import { useToast } from "@streamplace/components/src/components/ui/toast"; +import { ImagePlus, X } from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Image, + Platform, + ScrollView, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { Button } from "../../../components/src/components/ui/button"; +import { Textarea } from "../../../components/src/components/ui/textarea"; +import { selectUserProfile } from "../../features/bluesky/blueskySlice"; +import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; +import { useLiveUser } from "../../hooks/useLiveUser"; +import { useAppSelector } from "../../store/hooks"; + +const { flex, p, px, py, gap, layout, bg, borders, text, r, w, typography } = + zero; + +const isWeb = Platform.OS === "web"; + +const ButtonSelector = ({ + values, + selectedValue, + setSelectedValue, + disabledValues = [], + style = [], +}: { + values: { label: string; value: string }[]; + selectedValue: string; + setSelectedValue: (value: string) => void; + disabledValues?: string[]; + style?: any[]; +}) => ( + + {values.map(({ label, value }) => ( + + ))} + +); + +const ImageUploadComponent = ({ + selectedImage, + onImageSelect, + onImageRemove, +}: { + selectedImage?: string | File | Blob; + onImageSelect?: () => void; + onImageRemove?: () => void; +}) => { + const imageUrl = useMemo(() => { + if (!selectedImage) return undefined; + if (selectedImage instanceof File || selectedImage instanceof Blob) { + return URL.createObjectURL(selectedImage); + } + return selectedImage; + }, [selectedImage]); + + const containerStyle = useMemo( + () => [ + borders.width.thin, + borders.color.neutral[600], + bg.neutral[800], + r.md, + layout.flex.center, + { + height: 200, + borderStyle: "dashed", + }, + ], + [], + ); + + const imageStyle = useMemo( + () => [ + r.md, + { + width: "100%", + height: 200, + resizeMode: "cover" as const, + }, + ], + [], + ); + + const removeButtonStyle = useMemo( + () => [ + { + position: "absolute" as const, + top: 8, + right: 8, + backgroundColor: "rgba(0, 0, 0, 0.7)", + borderRadius: 12, + width: 24, + height: 24, + }, + layout.flex.center, + ], + [], + ); + + return ( + + + Thumbnail (Optional) + + + {selectedImage ? ( + + + + + + + ) : ( + + + + Add thumbnail image + + + Optional • JPG, PNG up to 975KB + + + )} + + ); +}; + +function LivestreamPanel() { + const { toastController, ToastComponent } = useToast(); + const userIsLive = useLiveUser(); + const captureFrame = useCaptureVideoFrame(); + const profile = useAppSelector(selectUserProfile); + const livestream = useLivestream(); + const createStreamRecord = useCreateStreamRecord(); + const updateStreamRecord = useUpdateStreamRecord(); + + const [title, setTitle] = useState(""); + const [loading, setLoading] = useState(false); + const [selectedImage, setSelectedImage] = useState< + string | File | Blob | undefined + >(); + const [mode, setMode] = useState<"create" | "edit">( + livestream ? "edit" : "create", + ); + const [toastTimeoutId, setToastTimeoutId] = useState( + null, + ); + + const handleModeChange = useCallback((newMode: "create" | "edit") => { + setMode(newMode); + }, []); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) return; + + setLoading(true); + + try { + let thumbnailToUse = selectedImage; + + // Auto-capture frame if no custom thumbnail and we have capture capability + if (!thumbnailToUse && mode === "create" && captureFrame && isWeb) { + try { + const capturedFrame = await captureFrame(1280, 0.85); + if (capturedFrame) { + thumbnailToUse = capturedFrame; + } + } catch (captureError) { + console.warn("Failed to capture video frame:", captureError); + } + } + + if (mode === "create") { + await createStreamRecord( + title.trim(), + thumbnailToUse as Blob | undefined, + true, + ); + } else { + await updateStreamRecord( + title.trim(), + livestream, + thumbnailToUse as Blob | undefined, + ); + } + + // Clear any existing timeout + if (toastTimeoutId) { + clearTimeout(toastTimeoutId); + } + + // Show success message + const toastTitle = + mode === "create" ? "Livestream announced" : "Livestream updated"; + + toastController.show(toastTitle, title.trim(), { duration: 4 }); + + // Add manual timeout as fallback + const timeoutId = setTimeout(() => { + toastController.hide(); + }, 4500); + setToastTimeoutId(timeoutId); + + // Clear form on successful create + if (mode === "create") { + setTitle(""); + setSelectedImage(undefined); + } + } catch (error) { + console.error("Error with livestream:", error); + + try { + // Clear any existing timeout + if (toastTimeoutId) { + clearTimeout(toastTimeoutId); + } + + // Truncate very long error messages + const errorMessage = String(error); + const truncatedError = + errorMessage.length > 200 + ? errorMessage.substring(0, 200) + "..." + : errorMessage; + + const errorTitle = + mode === "create" + ? "Error creating livestream" + : "Error updating livestream"; + + toastController.show(errorTitle, truncatedError, { duration: 5 }); + + // Add manual timeout as fallback + const timeoutId = setTimeout(() => { + toastController.hide(); + }, 5500); + setToastTimeoutId(timeoutId); + } catch (toastError) { + console.error("Error showing toast:", toastError); + } + } finally { + setLoading(false); + } + }, [ + title, + selectedImage, + mode, + captureFrame, + createStreamRecord, + updateStreamRecord, + livestream, + toastController, + ]); + + const handleImageSelect = useCallback(() => { + // Default web file picker behavior + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + setSelectedImage(file); + } + }; + input.click(); + }, []); + + const handleImageRemove = useCallback(() => { + setSelectedImage(undefined); + }, []); + + const noLivestream = mode === "edit" && !livestream; + + const disabled = useMemo( + () => !userIsLive || loading || title.trim() === "" || noLivestream, + [userIsLive, loading, title, noLivestream], + ); + + const buttonText = useMemo(() => { + if (loading) return "Loading..."; + if (!userIsLive) { + return mode === "create" + ? "Waiting for stream to start..." + : "Waiting for stream to start..."; + } + return mode === "create" ? "Announce Livestream!" : "Update Livestream!"; + }, [loading, userIsLive, mode]); + + // Clean up toast and timeout on unmount + useEffect(() => { + return () => { + if (toastTimeoutId) { + clearTimeout(toastTimeoutId); + } + toastController.hide(); + }; + }, [toastController]); + + return ( + <> + + + + + Stream Settings + + + + + {mode === "edit" && ( + + Change your Current Livestream Title + + )} + + {noLivestream ? ( + + + No active livestream to edit. Start a livestream first! + + + ) : ( + + + + + Streamer + + + @{profile?.handle || "streamer"} + + + + + Title + + +