From 111405ce6745aa290a7d7f080c8f1ff78f7296c5 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Thu, 11 Jun 2026 17:42:32 -0500 Subject: [PATCH] dashboard --- .../content-metadata-form.tsx | 2 +- js/i18n/locales/en-US/common.ftl | 4 + js/web/package.json | 4 +- js/web/plan.md | 63 +- .../components/dashboard/activity-picker.tsx | 332 ++++++++ .../src/components/dashboard/chat-panel.tsx | 53 ++ .../components/dashboard/control-panel.tsx | 449 +++++++++++ .../components/dashboard/dashboard-chrome.tsx | 187 +++++ .../dashboard/dashboard-metrics.tsx | 208 +++++ .../dashboard/dashboard-store-context.tsx | 28 + .../components/dashboard/hooks/use-layout.ts | 113 +++ js/web/src/components/dashboard/index.ts | 19 + .../components/dashboard/layout-renderer.tsx | 275 +++++++ js/web/src/components/dashboard/layout.ts | 288 +++++++ .../dashboard/multistream-status.tsx | 213 +++++ js/web/src/components/dashboard/problems.tsx | 105 +++ js/web/src/components/dashboard/registry.tsx | 103 +++ .../components/dashboard/resize-handle.tsx | 78 ++ .../components/dashboard/stream-health.tsx | 506 ++++++++++++ .../src/components/dashboard/stream-info.tsx | 736 ++++++++++++++++++ .../components/dashboard/stream-monitor.tsx | 144 ++++ .../src/components/dashboard/widget-leaf.tsx | 238 ++++++ js/web/src/components/header.tsx | 101 ++- .../src/components/player/player-controls.tsx | 98 ++- js/web/src/components/player/player.tsx | 4 - js/web/src/components/sidebar.tsx | 27 +- .../src/components/stream/danmu-overlay.tsx | 182 +++-- .../components/stream/livestream-provider.tsx | 118 +++ .../src/components/stream/use-danmu-lanes.ts | 99 +++ .../src/components/stream/video-section.tsx | 50 +- js/web/src/components/svg/mu.tsx | 33 + js/web/src/components/ui/admonition.tsx | 157 ++++ js/web/src/contexts/fullscreen-context.tsx | 28 +- js/web/src/lib/content-licenses.ts | 60 ++ js/web/src/lib/content-warnings.ts | 64 ++ js/web/src/lib/game.ts | 23 + .../lib/store/slices/contentMetadataSlice.ts | 36 +- js/web/src/routeTree.gen.ts | 233 +++--- js/web/src/routes/$user/index.tsx | 29 +- js/web/src/routes/$user/video/$tid.tsx | 98 +-- js/web/src/routes/__root.tsx | 131 +++- js/web/src/routes/dashboard/index.tsx | 51 ++ .../keys.tsx => dashboard/keys/index.tsx} | 6 +- .../multistream/index.tsx} | 8 +- .../recommendations/index.tsx} | 307 ++++---- js/web/src/routes/dashboard/stream/index.tsx | 673 ++++++++++++++++ .../webhooks/index.tsx} | 8 +- js/web/src/routes/settings.tsx | 31 - js/web/src/routes/settings/streaming.tsx | 88 --- js/web/src/styles.css | 32 + pnpm-lock.yaml | 372 +++++++-- 51 files changed, 6592 insertions(+), 703 deletions(-) create mode 100644 js/web/src/components/dashboard/activity-picker.tsx create mode 100644 js/web/src/components/dashboard/chat-panel.tsx create mode 100644 js/web/src/components/dashboard/control-panel.tsx create mode 100644 js/web/src/components/dashboard/dashboard-chrome.tsx create mode 100644 js/web/src/components/dashboard/dashboard-metrics.tsx create mode 100644 js/web/src/components/dashboard/dashboard-store-context.tsx create mode 100644 js/web/src/components/dashboard/hooks/use-layout.ts create mode 100644 js/web/src/components/dashboard/index.ts create mode 100644 js/web/src/components/dashboard/layout-renderer.tsx create mode 100644 js/web/src/components/dashboard/layout.ts create mode 100644 js/web/src/components/dashboard/multistream-status.tsx create mode 100644 js/web/src/components/dashboard/problems.tsx create mode 100644 js/web/src/components/dashboard/registry.tsx create mode 100644 js/web/src/components/dashboard/resize-handle.tsx create mode 100644 js/web/src/components/dashboard/stream-health.tsx create mode 100644 js/web/src/components/dashboard/stream-info.tsx create mode 100644 js/web/src/components/dashboard/stream-monitor.tsx create mode 100644 js/web/src/components/dashboard/widget-leaf.tsx create mode 100644 js/web/src/components/stream/livestream-provider.tsx create mode 100644 js/web/src/components/stream/use-danmu-lanes.ts create mode 100644 js/web/src/components/svg/mu.tsx create mode 100644 js/web/src/components/ui/admonition.tsx create mode 100644 js/web/src/lib/content-licenses.ts create mode 100644 js/web/src/lib/content-warnings.ts create mode 100644 js/web/src/lib/game.ts create mode 100644 js/web/src/routes/dashboard/index.tsx rename js/web/src/routes/{settings/keys.tsx => dashboard/keys/index.tsx} (96%) rename js/web/src/routes/{settings/multistream.tsx => dashboard/multistream/index.tsx} (98%) rename js/web/src/routes/{settings/recommendations.tsx => dashboard/recommendations/index.tsx} (59%) create mode 100644 js/web/src/routes/dashboard/stream/index.tsx rename js/web/src/routes/{settings/webhooks.tsx => dashboard/webhooks/index.tsx} (98%) delete mode 100644 js/web/src/routes/settings/streaming.tsx diff --git a/js/components/src/components/content-metadata/content-metadata-form.tsx b/js/components/src/components/content-metadata/content-metadata-form.tsx index a21ec815..18028b27 100644 --- a/js/components/src/components/content-metadata/content-metadata-form.tsx +++ b/js/components/src/components/content-metadata/content-metadata-form.tsx @@ -402,7 +402,7 @@ export const ContentMetadataForm = forwardRef( - Your node may prohibit some of this content. Read the + Your node may prohibit some of this content. Read their community guidelines to make sure.{" "} diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 94f6b2be..01bda79c 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -157,6 +157,10 @@ back-to-home = Back to home download-video = Download video untitled = Untitled default-stream-title = A livestream! +pip-enter = Picture-in-picture +pip-exit = Exit picture-in-picture +theatre-enter = Theatre mode +theatre-exit = Exit theatre mode ## Notifications teleporting-in = Teleporting in diff --git a/js/web/package.json b/js/web/package.json index a7480f3a..53fdfc56 100644 --- a/js/web/package.json +++ b/js/web/package.json @@ -14,8 +14,9 @@ "@atproto/api": "^0.19.3", "@atproto/oauth-client-browser": "^0.4.1", "@base-ui/react": "^1.5.0", + "@dnd-kit/helpers": "^0.4.0", + "@dnd-kit/react": "^0.4.0", "@fontsource/atkinson-hyperlegible-next": "^5.2.7", - "@hello-pangea/dnd": "^18.0.1", "@sentry/react": "^10.57.0", "@streamplace/core": "workspace:*", "@streamplace/i18n": "workspace:*", @@ -37,6 +38,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^15.7.3", + "recharts": "^2.15.4", "sonare": "^0.0.1", "sonner": "^2.0.7", "streamplace": "workspace:*", diff --git a/js/web/plan.md b/js/web/plan.md index e0469379..133fef62 100644 --- a/js/web/plan.md +++ b/js/web/plan.md @@ -443,6 +443,54 @@ Full light mode + dark mode implementation. `prefers-color-scheme` media query l --- +## Progress + +### Phase 1: Foundation — Complete + +| Item | Status | Notes | +| ---------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 1a. React Query | Done | `@tanstack/react-query` in main.tsx. `useVideoList` migrated to `useInfiniteQuery`. `fetchLiveUsers` migrated to `useQuery` with 30s interval. | +| 1b. Error boundary | Done | React error boundary at router level with fallback UI and retry. | +| 1c. Suspense fallbacks | Done | Route-level Suspense with loading skeletons. | +| 1d. Mount providers | Done | TooltipProvider, FullscreenProvider, VideoElementProvider mounted. | +| 1e. Theme provider | Done | Media query listener, manual toggle, localStorage persistence. | +| 1f. i18n audit | Done | Keys added to en-US common.ftl. Stream/video components updated. | +| 1g. Sentry | Done | `@sentry/react` initialized in main.tsx, source maps in Vite build. | +| 1h. Code review | Done | Zustand slices audited, dead code removed. | + +### Phase 2: Feature Parity — In Progress + +| Item | Status | Notes | +| -------------------------- | ------- | ------------------------------------------------------------------------------------------------ | +| 2a. Chat popout | Done | `/chat-popout/$user` route, standalone window, WebSocket chat. | +| 2b. Block/mute in chat | Pending | `createBlockRecord` still stubbed. UI buttons not wired. | +| 2c. Chat profile edit | Done | Inline editor with color picker, avatar, badges. | +| 2d. VOD download | Done | Fetch + Blob + createObjectURL + anchor download. | +| 2e. Multi-stream viewer | Pending | Not started. | +| 2f. Search | Done | Inline header bar with popover results. | +| 2g. Upload VOD | Pending | Not started. | +| 2h. Embed screens | Done | `/embed/$user`, `/embed/$user/video/$tid`, `/embed/info-widget/$user`, `/embed/danmu-obs/$user`. | +| 2i. Popout windows | Pending | Not started. | +| 2k. About/Download/Support | Pending | Not started. | + +### Additional Work Done (not in original plan) + +- Reply + pin message actions in chat +- Danmu (bullet comments) overlay with mobile-parity implementation +- Danmu toggle persisted to localStorage, standalone button in player controls +- Migrated fetchLiveUsers from zustand to React Query + +### Phase 3: Go-Live — Not Started + +### Phase 4: Web-Native Enhancements — In Progress + +| Item | Status | Notes | +| ---------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 4a. Picture-in-Picture | Done | PiP toggle button in player controls. Uses `requestPictureInPicture()` API. Support detection, state tracking via events. | +| 4b. Theatre mode | Done | Sidebar + header hidden, video fills viewport. Toggle button in player controls, `T` keyboard shortcut. State persisted to localStorage. Works on both live streams and VODs. | + +--- + ## Suggested Priority Order ### Phase 1: Foundation (Do First) @@ -479,10 +527,11 @@ Full light mode + dark mode implementation. `prefers-color-scheme` media query l ### Phase 4: Web-Native Enhancements (Nice-to-Have) -1. Picture-in-Picture (4a) -2. Web Share API (4b) -3. Keyboard shortcuts (4c) -4. Multi-window player (4d) -5. Deep linking (4e) -6. Theming (4f) -7. PWA (4g) +1. Picture-in-Picture (4a) — Done +2. Theatre mode — Done +3. Web Share API (4b) +4. Keyboard shortcuts (4c) +5. Multi-window player (4d) +6. Deep linking (4e) +7. Theming (4f) +8. PWA (4g) diff --git a/js/web/src/components/dashboard/activity-picker.tsx b/js/web/src/components/dashboard/activity-picker.tsx new file mode 100644 index 00000000..85f055b4 --- /dev/null +++ b/js/web/src/components/dashboard/activity-picker.tsx @@ -0,0 +1,332 @@ +import { getDidFromAtUri, getGameCoverUrl } from "@/lib/game"; +import { usePDSAgent } from "@/lib/store/hooks"; +import { cn } from "@/lib/utils"; +import { Search, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PlaceStreamLivestream } from "streamplace"; + +/** + * Activity picker for livestreams. Mirrors the RN's ActivityPicker: + * - "Game" mode: search for actual games via the streamplace xrpc, + * show cover art + name + genres. Stores an `ActivityGame` value + * with `$type: "place.stream.defs#activityGame"`. + * - "Other Activity" mode: predefined labels (Just Chatting, Music, …). + * Stores an `ActivityLabel` value. + */ + +interface GameResult { + uri: string; + name: string; + coverUrl?: string; + genres?: string[]; +} + +const ACTIVITY_LABELS: Array<{ value: string; display: string }> = [ + { value: "just_chatting", display: "Just Chatting" }, + { value: "music", display: "Music" }, + { value: "art", display: "Art" }, + { value: "software_dev", display: "Software Dev" }, + { value: "miniatures", display: "Miniatures" }, + { value: "events", display: "Events" }, + { value: "makers_crafting", display: "Makers" }, + { value: "cooking", display: "Cooking" }, + { value: "fitness", display: "Fitness" }, + { value: "sports", display: "Sports" }, +]; + +type Mode = "game" | "label"; + +interface ActivityPickerProps { + value: PlaceStreamLivestream.Record["activity"] | undefined; + onChange: ( + activity: PlaceStreamLivestream.Record["activity"] | undefined, + ) => void; +} + +export function ActivityPicker({ value, onChange }: ActivityPickerProps) { + const { t } = useTranslation("common"); + const agent = usePDSAgent(); + + // Determine initial mode from the current value + const initialMode = useMemo(() => { + if (value?.$type === "place.stream.defs#activityLabel") return "label"; + if (value?.$type === "place.stream.defs#activityGame") return "game"; + return "game"; + }, []); + + const [mode, setMode] = useState(initialMode); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const debounceRef = useRef | null>(null); + + const selectedGame = + value?.$type === "place.stream.defs#activityGame" + ? (value as { uri: string; name: string }) + : null; + const selectedLabel = + value?.$type === "place.stream.defs#activityLabel" + ? (value as { label: string }) + : null; + + // Debounced game search + useEffect(() => { + if (mode !== "game") { + setResults([]); + return; + } + if (!query.trim() || !agent) { + setResults([]); + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setSearching(true); + try { + const res = await agent.place.stream.game.search({ + q: query, + limit: 8, + }); + const games: GameResult[] = []; + for (const result of res.data.results ?? []) { + // Type guard: only game summary views have the fields we need + if (!result || typeof result !== "object" || !("name" in result)) { + continue; + } + const r = result as { + uri: string; + name: string; + media?: any; + genres?: string[]; + }; + const did = getDidFromAtUri(r.uri); + const coverUrl = getGameCoverUrl(r.media, did); + games.push({ + uri: r.uri, + name: r.name, + coverUrl, + genres: r.genres, + }); + } + setResults(games); + } catch (e) { + console.error("game search error:", e); + setResults([]); + } finally { + setSearching(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, agent, mode]); + + const selectGame = (game: GameResult) => { + onChange({ + $type: "place.stream.defs#activityGame", + uri: game.uri, + name: game.name, + } as unknown as PlaceStreamLivestream.Record["activity"]); + setQuery(""); + setResults([]); + }; + + const clearActivity = () => { + onChange(undefined); + setQuery(""); + setResults([]); + }; + + return ( +
+ + + {/* Mode toggle */} +
+ { + setMode("game"); + if (selectedLabel) clearActivity(); + }} + label={t("activity-mode-game", { defaultValue: "Game" })} + /> + { + setMode("label"); + if (selectedGame) clearActivity(); + }} + label={t("activity-mode-other", { defaultValue: "Other Activity" })} + /> +
+ + {/* Game mode */} + {mode === "game" && + (selectedGame ? ( + + ) : ( +
+
+ + setQuery(e.target.value)} + placeholder={t("activity-search-placeholder", { + defaultValue: "Search for a game…", + })} + className="w-full h-8 pl-8 pr-2 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-fg)] placeholder:text-[var(--color-fg-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-ring)]" + /> +
+ {searching && ( +
+ {t("searching", { defaultValue: "Searching…" })} +
+ )} + {!searching && results.length > 0 && ( +
+ {results.map((game) => ( + + ))} +
+ )} + {!searching && query.trim() && results.length === 0 && ( +
+ {t("no-results", { defaultValue: "No games found" })} +
+ )} +
+ ))} + + {/* Label mode */} + {mode === "label" && ( +
+ {ACTIVITY_LABELS.map(({ value: labelValue, display }) => { + const isSelected = selectedLabel?.label === labelValue; + return ( + + ); + })} +
+ )} +
+ ); +} + +function ModeButton({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} + +function SelectedGameCard({ + uri, + name, + onClear, +}: { + uri: string; + name: string; + onClear: () => void; +}) { + const did = getDidFromAtUri(uri); + // We don't have the cover URL here without re-fetching; show the name + // and let the X clear it. (Re-fetching is what the RN does in + // useEffect, but it'd be extra round-trips; the search results above + // already showed the cover so the user has seen it.) + void did; + return ( +
+
+
+ {name} +
+
+ {uri} +
+
+ +
+ ); +} diff --git a/js/web/src/components/dashboard/chat-panel.tsx b/js/web/src/components/dashboard/chat-panel.tsx new file mode 100644 index 00000000..aa2d694b --- /dev/null +++ b/js/web/src/components/dashboard/chat-panel.tsx @@ -0,0 +1,53 @@ +import type { LivestreamStore } from "@streamplace/core"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useStore } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import { ChatInput } from "../stream/chat-input"; +import { ChatPanel as ChatMessages } from "../stream/chat-panel"; + +/** + * Dashboard chat panel with message count and messages/min rate. + * Wraps the existing ChatPanel and ChatInput components. + */ +export function ChatPanelWidget({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); + const state = useStore( + store, + useShallow((s) => ({ + chat: s.chat, + websocketConnected: s.websocketConnected, + livestream: s.livestream, + })), + ); + + const isConnected = state.websocketConnected; + const isLive = !!state.livestream; + + // Messages per minute calculation + const messagesPerMinute = useMemo(() => { + if (!state.chat) return 0; + const oneMinuteAgo = Date.now() - 60 * 1000; + return state.chat.filter((msg) => { + try { + return new Date(msg.indexedAt).getTime() > oneMinuteAgo; + } catch { + return false; + } + }).length; + }, [state.chat]); + + return ( +
+ {/* Messages */} +
+ +
+ + {/* Input */} +
+ +
+
+ ); +} diff --git a/js/web/src/components/dashboard/control-panel.tsx b/js/web/src/components/dashboard/control-panel.tsx new file mode 100644 index 00000000..b2341782 --- /dev/null +++ b/js/web/src/components/dashboard/control-panel.tsx @@ -0,0 +1,449 @@ +import { cn } from "@/lib/utils"; +import { DragDropProvider, useDraggable } from "@dnd-kit/react"; +import type { LivestreamStore } from "@streamplace/core"; +import { Plus, RotateCcw, Save, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "../ui/button"; +import { useLayout } from "./hooks/use-layout"; +import { + removeLeafAt, + replaceLeafWithSplit, + updateLeafAt, + type LayoutNode, +} from "./layout"; +import { + DraggableLayoutRenderer, + findElementById, + getLeafWidget, + idToPath, + readPointer, + type DragState, +} from "./layout-renderer"; +import { WIDGET_KEYS, WIDGET_REGISTRY, getWidgetMeta } from "./registry"; + +interface ControlPanelProps { + store: LivestreamStore; + user?: string; +} + +/** Prefix for drawer item source ids (e.g. `widget-stream-monitor`). */ +const WIDGET_SOURCE_PREFIX = "widget-"; + +/** + * Top-level control panel. Owns the drag-and-drop context, the widget + * drawer, and the layout renderer. The drawer and the layout live inside + * the same `DragDropProvider` so the user can drag drawer items onto + * leaves. + */ +export function ControlPanel({ store, user }: ControlPanelProps) { + const { + layout, + setLayout, + resetLayout, + presets, + savePreset, + loadPreset, + deletePreset, + } = useLayout(); + + const [presetName, setPresetName] = useState(""); + const [showSaveInput, setShowSaveInput] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [dragState, setDragState] = useState(null); + + const handleUpdate = useCallback( + (updater: (node: LayoutNode) => LayoutNode) => { + setLayout(updater(layout)); + }, + [layout, setLayout], + ); + + const handleWidgetChange = useCallback( + (path: number[], newWidget: string) => { + if (!(newWidget in WIDGET_REGISTRY)) return; + setLayout(updateLeafAt(layout, path, newWidget)); + }, + [layout, setLayout], + ); + + const handleWidgetRemove = useCallback( + (path: number[]) => { + setLayout(removeLeafAt(layout, path)); + }, + [layout, setLayout], + ); + + const handleSavePreset = useCallback(() => { + const name = presetName.trim(); + if (!name) return; + savePreset(name); + setPresetName(""); + setShowSaveInput(false); + }, [presetName, savePreset]); + + // Live drag tracking. `onDragOver` fires on target change; `onDragMove` + // fires on every pointer move (so the overlay tracks the cursor even + // when it stays within a single leaf). + const handleDragOver = useCallback((event: any) => { + const target = event.operation?.target; + const pointer = readPointer(event.operation); + if (target && pointer) { + setDragState({ targetId: String(target.id), pointer }); + } else { + setDragState(null); + } + }, []); + + const handleDragMove = useCallback((event: any) => { + const target = event.operation?.target; + const pointer = readPointer(event.operation); + if (target && pointer) { + setDragState({ targetId: String(target.id), pointer }); + } + }, []); + + /** + * Unified drop handler. Supports two kinds of source: + * - leaf source (`leaf-{path}`): move the widget — split the target + * and remove the source from its original slot + * - drawer source (`widget-{key}`): add the widget — split the target + * with the dragged widget, no source removal + */ + const handleDragEnd = useCallback( + (event: any) => { + setDragState(null); + if (event.canceled) return; + const { source, target } = event.operation; + if (!source || !target) return; + + const sourceId = String(source.id); + const targetId = String(target.id); + if (sourceId === targetId) return; + + const isWidgetSource = sourceId.startsWith(WIDGET_SOURCE_PREFIX); + + // Resolve the source widget and (for leaf sources) its path. + let sourceWidget: string; + let sourcePath: number[] | null = null; + if (isWidgetSource) { + sourceWidget = sourceId.slice(WIDGET_SOURCE_PREFIX.length); + if (!(sourceWidget in WIDGET_REGISTRY)) return; + } else { + sourcePath = idToPath(sourceId); + if (!sourcePath) return; + const widget = getLeafWidget(layout, sourcePath); + if (!widget) return; + sourceWidget = widget; + } + + const targetPath = idToPath(targetId); + if (!targetPath) return; + const targetWidget = getLeafWidget(layout, targetPath); + + const targetEl = findElementById(targetId); + const pointer = readPointer(event.operation); + if (!targetEl || !pointer) return; + + const rect = targetEl.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const relX = (pointer.x - rect.left) / rect.width; + const relY = (pointer.y - rect.top) / rect.height; + if ( + !Number.isFinite(relX) || + !Number.isFinite(relY) || + relX < 0 || + relX > 1 || + relY < 0 || + relY > 1 + ) { + return; + } + + const finalSourceWidget = sourceWidget; + let nextLayout: LayoutNode; + + // Dropping onto a blank slot just fills it — no split, since a + // split with a blank in one half would be weird. For leaf + // sources we also remove the source so the widget effectively + // moves into the empty slot. + if (targetWidget === "blank") { + const filled = updateLeafAt(layout, targetPath, finalSourceWidget); + nextLayout = sourcePath ? removeLeafAt(filled, sourcePath) : filled; + } else { + // Dominant axis determines split direction. The sign of the + // displacement chooses which side the source widget lands on. + const horizontalDominant = Math.abs(relX - 0.5) >= Math.abs(relY - 0.5); + const direction = horizontalDominant ? "horizontal" : "vertical"; + const sourceFirst = horizontalDominant ? relX < 0.5 : relY < 0.5; + + const withSplit = replaceLeafWithSplit( + layout, + targetPath, + finalSourceWidget, + direction, + sourceFirst, + ); + nextLayout = sourcePath + ? removeLeafAt(withSplit, sourcePath) + : withSplit; + } + + setLayout(nextLayout); + }, + [layout, setLayout], + ); + + return ( + +
+ {/* Toolbar */} +
+ {/* Preset selector */} + {presets.length > 0 && ( + + )} + + {/* Save preset */} + {showSaveInput ? ( +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSavePreset(); + if (e.key === "Escape") setShowSaveInput(false); + }} + placeholder="Preset name…" + autoFocus + className="h-7 px-2 text-xs rounded border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-fg)] w-32" + /> + +
+ ) : ( + + )} + + {/* Preset management */} + {presets.length > 0 && ( +
+ {presets.map((p) => ( +
+ + +
+ ))} +
+ )} + +
+ + {/* Add Widget toggle */} + + + {/* Reset */} + +
+ + {/* Drawer (slides down between toolbar and layout) */} + setDrawerOpen(false)} /> + + {/* Layout area */} +
+ +
+
+ + ); +} + +/** + * Drawer with draggable cards for every widget in the registry (except + * `blank`, which is only a placeholder, not a real widget). Drag a card + * onto a leaf in the layout to add it. + * Positioned as a 'drawer' that slides down from the toolbar. + */ +function WidgetDrawer({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const [exiting, setExiting] = useState(false); + const exitingRef = useRef(false); + + useEffect(() => { + if (exiting) { + const timer = setTimeout(() => { + setExiting(false); + exitingRef.current = false; + onClose(); + }, 500); + return () => clearTimeout(timer); + } + }, [exiting, onClose]); + + const handleClose = useCallback(() => { + if (!exitingRef.current) { + exitingRef.current = true; + setExiting(true); + } + }, []); + + const outerDivRef = useCallback( + (el: HTMLDivElement | null) => { + if (el) { + const handleClick = (e: MouseEvent) => { + if (!el.contains(e.target as Node)) { + if ( + e.target instanceof HTMLElement && + e.target.closest("#drawer-toggle-button") + ) { + return; + } + handleClose(); + } + }; + document.addEventListener("mousedown", handleClick); + return () => { + document.removeEventListener("mousedown", handleClick); + }; + } + }, + [handleClose], + ); + + if (!open && !exiting) return null; + + return ( + <> +
+
+
+
+ Drag a widget to add it +
+ +
+
+ {WIDGET_KEYS.filter((k) => k !== "blank").map((key) => ( + + ))} +
+
+
+ + ); +} + +function DrawerWidgetCard({ widgetKey }: { widgetKey: string }) { + const { ref, isDragging } = useDraggable({ + id: `${WIDGET_SOURCE_PREFIX}${widgetKey}`, + }); + const meta = getWidgetMeta(widgetKey); + if (!meta) return null; + const Icon = meta.icon; + return ( +
+ + {meta.title} +
+ ); +} diff --git a/js/web/src/components/dashboard/dashboard-chrome.tsx b/js/web/src/components/dashboard/dashboard-chrome.tsx new file mode 100644 index 00000000..5c39061a --- /dev/null +++ b/js/web/src/components/dashboard/dashboard-chrome.tsx @@ -0,0 +1,187 @@ +import { LivestreamProvider } from "@/components/stream/livestream-provider"; +import { useSession } from "@/lib/session"; +import { Link, Outlet } from "@tanstack/react-router"; +import { + ArrowLeft, + Key, + LayoutGrid, + ListVideo, + Radio, + Share2, + Webhook, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "../ui/sidebar"; +import { DashboardMetricsProvider } from "./dashboard-metrics"; +import { DashboardStoreContext } from "./dashboard-store-context"; + +interface NavLink { + to: string; + icon: React.ComponentType<{ className?: string }>; + labelKey: string; +} + +/** Main nav — appears above the Settings group. */ +const MAIN_NAV: NavLink[] = [ + { to: "/dashboard", icon: LayoutGrid, labelKey: "control-panel" }, + { to: "/dashboard/stream", icon: Radio, labelKey: "stream-settings" }, +]; + +/** Settings group — appears under a "Settings" header. */ +const SETTINGS_NAV: NavLink[] = [ + { to: "/dashboard/keys", icon: Key, labelKey: "key-manager" }, + { to: "/dashboard/multistream", icon: Share2, labelKey: "multistream" }, + { + to: "/dashboard/recommendations", + icon: ListVideo, + labelKey: "recommendations-to-others", + }, + { to: "/dashboard/webhooks", icon: Webhook, labelKey: "webhooks" }, +]; + +const DASHBOARD_NAV_OPEN_KEY = "streamplace:dashboard-nav-open"; + +export default function DashboardChrome() { + const { t } = useTranslation("settings"); + const { state } = useSession(); + + const [open, setOpen] = useState(() => { + if (typeof localStorage === "undefined") return true; + return localStorage.getItem(DASHBOARD_NAV_OPEN_KEY) !== "false"; + }); + + if (state.status !== "authenticated") { + return ( +
+ {t("login-required", { + defaultValue: "Please log in to access the dashboard.", + })} +
+ ); + } + + const user = state.session.did; + + return ( + + {(store) => ( + + + { + setOpen(o); + if (typeof localStorage !== "undefined") { + localStorage.setItem(DASHBOARD_NAV_OPEN_KEY, String(o)); + } + }} + > + + +
+ +

+ {t("nav-dashboard", { defaultValue: "Creator Dashboard" })} +

+
+
+ +
+
+
+
+
+ )} +
+ ); +} + +function DashboardSidebar() { + const { t } = useTranslation("settings"); + const { state } = useSession(); + if (state.status !== "authenticated") { + return null; + } + const user = state.session.did; + + return ( + + + {/* Back to Streamplace */} + + + + + + + + Back to Streamplace + + + + + + + + {/* Main nav: Control Panel + Stream Settings */} + + + + {MAIN_NAV.map((item) => { + const Icon = item.icon; + return ( + + + + + {t(item.labelKey)} + + + + ); + })} + + + + + {/* Settings group: stream keys, multistream, recommendations, webhooks */} + + + {t("settings-group", { defaultValue: "Settings" })} + + + + {SETTINGS_NAV.map((item) => { + const Icon = item.icon; + return ( + + + + + {t(item.labelKey)} + + + + ); + })} + + + + + + ); +} diff --git a/js/web/src/components/dashboard/dashboard-metrics.tsx b/js/web/src/components/dashboard/dashboard-metrics.tsx new file mode 100644 index 00000000..9358bb1d --- /dev/null +++ b/js/web/src/components/dashboard/dashboard-metrics.tsx @@ -0,0 +1,208 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useStore } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import { useDashboardStore } from "./dashboard-store-context"; + +/** How often we sample the live metrics into the history buffer. */ +const SAMPLE_INTERVAL_MS = 2000; + +/** Range presets — sliding-window lengths. */ +export type DashboardRange = "1m" | "2m" | "5m" | "10m" | "25m"; + +export const DASHBOARD_RANGES: { + id: DashboardRange; + label: string; + samples: number; +}[] = [ + { id: "1m", label: "1m", samples: 30 }, + { id: "2m", label: "2m", samples: 60 }, + { id: "5m", label: "5m", samples: 150 }, + { id: "10m", label: "10m", samples: 300 }, + { id: "25m", label: "25m", samples: 750 }, +]; + +/** One sample of the metric history. */ +export interface DashboardMetricsPoint { + t: number; + bitrate: number; + viewers: number; + segmentTiming: number; + chatRate: number; + fps: number; +} + +function emptyPoint(t: number): DashboardMetricsPoint { + return { t, bitrate: 0, viewers: 0, segmentTiming: 0, chatRate: 0, fps: 0 }; +} + +export interface DashboardMetricsValue { + history: DashboardMetricsPoint[]; + range: DashboardRange; + setRange: (r: DashboardRange) => void; + /** True when there's a live stream. Used for the connection-quality + * indicator and to distinguish "pre-live" from a real bad reading. */ + hasLivestream: boolean; +} + +const DashboardMetricsContext = createContext( + null, +); + +/** + * Hook for consuming the dashboard's time-series metrics. Must be used + * inside ``. + */ +export function useDashboardMetrics(): DashboardMetricsValue { + const ctx = useContext(DashboardMetricsContext); + if (!ctx) { + throw new Error( + "useDashboardMetrics must be used inside ", + ); + } + return ctx; +} + +interface ProviderProps { + children: ReactNode; +} + +/** + * Continuously samples the live stream's metrics into a rolling-window + * history buffer. Mount this high in the tree (the dashboard chrome) so + * the buffer keeps populating while the user navigates between dashboard + * sub-routes. When the user is on a non-dashboard page the provider + * unmounts and tracking stops — the buffer is fresh on remount. + */ +export function DashboardMetricsProvider({ children }: ProviderProps) { + const store = useDashboardStore(); + const [range, setRange] = useState("2m"); + const historyLength = + DASHBOARD_RANGES.find((r) => r.id === range)?.samples ?? + DASHBOARD_RANGES[0].samples; + + const [history, setHistory] = useState(() => + Array.from({ length: historyLength }, () => emptyPoint(Date.now())), + ); + + // Refs for the live values that we sample into history on each tick. + // Keeping these out of state avoids re-rendering on every segment update. + const currentBitrateRef = useRef(0); + const currentSegmentTimingRef = useRef(0); + const currentFpsRef = useRef(0); + const latestInputsRef = useRef({ chat: [] as any[], viewers: 0 }); + latestInputsRef.current = { + chat: store.getState().chat, + viewers: store.getState().viewers ?? 0, + }; + + // Subscribe to the store so the refs stay current. We don't render off + // of these — they're read inside the sampling interval. + const storeState = useStore( + store, + useShallow((s) => ({ + segment: s.segment, + livestream: s.livestream, + chat: s.chat, + viewers: s.viewers, + })), + ); + + // Update bitrate from the current segment. + useEffect(() => { + const seg = store.getState().segment; + if (!seg?.size || !seg?.duration) return; + const kbps = (seg.size * 8) / (seg.duration / 1_000_000_000) / 1000; + currentBitrateRef.current = kbps; + }, [ + store, + store.getState().segment?.size, + store.getState().segment?.duration, + ]); + + // Update FPS from the current segment's video track. + useEffect(() => { + const seg = store.getState().segment; + const videoTrack = seg?.video?.[0]; + if (!videoTrack?.framerate) return; + const { num, den } = videoTrack.framerate; + if (!den) return; + currentFpsRef.current = num / den; + }, [store, store.getState().segment?.video]); + + // Update segment timing (ms since previous segment). + const lastSegmentAtRef = useRef(null); + useEffect(() => { + const seg = store.getState().segment; + if (!seg) return; + const now = Date.now(); + if (lastSegmentAtRef.current !== null) { + currentSegmentTimingRef.current = now - lastSegmentAtRef.current; + } + lastSegmentAtRef.current = now; + }, [store, store.getState().segment]); + + // When the user changes the range, resize the history buffer. The window + // then refills as new samples arrive. + useEffect(() => { + setHistory( + Array.from({ length: historyLength }, () => emptyPoint(Date.now())), + ); + }, [historyLength]); + + // Sample on a fixed interval. Captures the current bitrate, segment + // timing, FPS, viewers, and chat rate into the history ring buffer. + useEffect(() => { + const id = setInterval(() => { + const now = Date.now(); + const state = store.getState(); + const chat = state.chat ?? []; + const viewers = state.viewers ?? 0; + const oneMinuteAgo = now - 60_000; + const chatRate = chat.filter((msg: any) => { + try { + return new Date(msg.indexedAt).getTime() > oneMinuteAgo; + } catch { + return false; + } + }).length; + + setHistory((prev) => { + const next = prev.slice(1); + next.push({ + t: now, + bitrate: currentBitrateRef.current, + viewers, + segmentTiming: currentSegmentTimingRef.current, + chatRate, + fps: currentFpsRef.current, + }); + return next; + }); + }, SAMPLE_INTERVAL_MS); + return () => clearInterval(id); + }, [store]); + + const value = useMemo( + () => ({ + history, + range, + setRange, + hasLivestream: !!storeState.livestream, + }), + [history, range, storeState.livestream], + ); + + return ( + + {children} + + ); +} diff --git a/js/web/src/components/dashboard/dashboard-store-context.tsx b/js/web/src/components/dashboard/dashboard-store-context.tsx new file mode 100644 index 00000000..cdc6e6b8 --- /dev/null +++ b/js/web/src/components/dashboard/dashboard-store-context.tsx @@ -0,0 +1,28 @@ +import type { LivestreamStore } from "@streamplace/core"; +import { createContext, useContext } from "react"; + +/** + * Context that exposes the per-user `LivestreamStore` to all dashboard + * sub-routes. Set up by `DashboardChrome`, consumed by the metrics + * tracker, the stream-health widget, and the control panel. + */ +export const DashboardStoreContext = createContext( + null, +); + +export function useIsDashboardStoreReady(): boolean { + const store = useContext(DashboardStoreContext); + return !!store; +} + +/** + * Hook for consuming the dashboard's `LivestreamStore`. Must be used inside + * ``, which provides the context. + */ +export function useDashboardStore(): LivestreamStore { + const store = useContext(DashboardStoreContext); + if (!store) { + throw new Error("useDashboardStore must be used inside "); + } + return store; +} diff --git a/js/web/src/components/dashboard/hooks/use-layout.ts b/js/web/src/components/dashboard/hooks/use-layout.ts new file mode 100644 index 00000000..f67363a2 --- /dev/null +++ b/js/web/src/components/dashboard/hooks/use-layout.ts @@ -0,0 +1,113 @@ +import { useCallback, useState } from "react"; +import { + DEFAULT_LAYOUT, + type LayoutNode, + type LayoutPreset, + validateLayout, +} from "../layout"; + +const STORAGE_KEY = "dashboard:layout"; +const PRESETS_KEY = "dashboard:presets"; + +function loadFromStorage(): LayoutNode { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + const valid = validateLayout(parsed); + if (valid) return valid; + } + } catch { + // corrupted storage, fall through to default + } + return DEFAULT_LAYOUT; +} + +function saveToStorage(layout: LayoutNode): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); + } catch { + // storage full or unavailable + } +} + +function loadPresets(): LayoutPreset[] { + try { + const stored = localStorage.getItem(PRESETS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return parsed.filter( + (p): p is LayoutPreset => + p && + typeof p.name === "string" && + validateLayout(p.layout) !== null, + ); + } + } + } catch {} + return []; +} + +function savePresets(presets: LayoutPreset[]): void { + try { + localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); + } catch {} +} + +export function useLayout() { + const [layout, setLayoutState] = useState(loadFromStorage); + const [presets, setPresetsState] = useState(loadPresets); + + const setLayout = useCallback((newLayout: LayoutNode) => { + setLayoutState(newLayout); + saveToStorage(newLayout); + }, []); + + const resetLayout = useCallback(() => { + setLayoutState(DEFAULT_LAYOUT); + saveToStorage(DEFAULT_LAYOUT); + }, []); + + const savePreset = useCallback( + (name: string) => { + const newPresets = [ + ...presets.filter((p) => p.name !== name), + { name, layout }, + ]; + setPresetsState(newPresets); + savePresets(newPresets); + }, + [layout, presets], + ); + + const loadPreset = useCallback( + (name: string) => { + const preset = presets.find((p) => p.name === name); + if (preset) { + setLayoutState(preset.layout); + saveToStorage(preset.layout); + } + }, + [presets], + ); + + const deletePreset = useCallback( + (name: string) => { + const newPresets = presets.filter((p) => p.name !== name); + setPresetsState(newPresets); + savePresets(newPresets); + }, + [presets], + ); + + return { + layout, + setLayout, + resetLayout, + presets, + savePreset, + loadPreset, + deletePreset, + }; +} diff --git a/js/web/src/components/dashboard/index.ts b/js/web/src/components/dashboard/index.ts new file mode 100644 index 00000000..3b219c7b --- /dev/null +++ b/js/web/src/components/dashboard/index.ts @@ -0,0 +1,19 @@ +export { ChatPanelWidget } from "./chat-panel"; +export { ControlPanel } from "./control-panel"; +export { useLayout } from "./hooks/use-layout"; +export { + DEFAULT_LAYOUT, + updateLeafAt, + validateLayout, + type LayoutNode, + type LeafNode, + type SplitNode, +} from "./layout"; +export { DraggableLayoutRenderer, LayoutRenderer } from "./layout-renderer"; +export { MultistreamStatusWidget } from "./multistream-status"; +export { ProblemsWidget } from "./problems"; +export { WIDGET_KEYS, WIDGET_REGISTRY, getWidgetMeta } from "./registry"; +export { StreamHealthWidget } from "./stream-health"; +export { StreamInfoWidget } from "./stream-info"; +export { StreamMonitorWidget } from "./stream-monitor"; +export { WidgetLeaf } from "./widget-leaf"; diff --git a/js/web/src/components/dashboard/layout-renderer.tsx b/js/web/src/components/dashboard/layout-renderer.tsx new file mode 100644 index 00000000..29457aa0 --- /dev/null +++ b/js/web/src/components/dashboard/layout-renderer.tsx @@ -0,0 +1,275 @@ +import type { LivestreamStore } from "@streamplace/core"; +import { useCallback } from "react"; +import type { LayoutNode } from "./layout"; +import { ResizeHandle } from "./resize-handle"; +import { WidgetLeaf } from "./widget-leaf"; + +interface LayoutRendererProps { + node: LayoutNode; + store: LivestreamStore; + user?: string; + /** Called when the layout tree should be updated. */ + onUpdate: (updater: (node: LayoutNode) => LayoutNode) => void; + /** Path of this node in the tree (array of child indices). */ + path?: number[]; + /** Called when a leaf's widget type is swapped via dropdown. */ + onWidgetChange?: (path: number[], newWidget: string) => void; + /** Called when a leaf's close button is clicked to remove the slot. */ + onWidgetRemove?: (path: number[]) => void; + /** Current drop target and pointer position, for split-preview rendering. */ + dragState?: DragState | null; +} + +/** Live drag state tracked at the renderer level. */ +export interface DragState { + targetId: string; + /** Pointer position in viewport coordinates. */ + pointer: { x: number; y: number }; +} + +/** + * Adjusts the ratio between two adjacent children in a split node. + * childIndex is the left/top child; delta is the fraction to shift. + */ +function adjustRatio( + root: LayoutNode, + path: number[], + childIndex: number, + delta: number, +): LayoutNode { + if (path.length === 0) { + if (root.type !== "split") return root; + const { ratio, children } = root; + if (childIndex < 0 || childIndex >= ratio.length - 1) return root; + + const minRatio = 0.05; + const newRatio = [...ratio]; + const shift = Math.max( + -newRatio[childIndex] + minRatio, + Math.min(delta, newRatio[childIndex + 1] - minRatio), + ); + newRatio[childIndex] += shift; + newRatio[childIndex + 1] -= shift; + + return { ...root, ratio: newRatio }; + } + + if (root.type !== "split") return root; + const [head, ...rest] = path; + const newChildren = root.children.map((child, i) => + i === head ? adjustRatio(child, rest, childIndex, delta) : child, + ); + return { ...root, children: newChildren }; +} + +/** + * Interleaved renderer: places resize handles between children + * so they sit on the boundary between two flex children. + */ +export function LayoutRenderer({ + node, + store, + user, + onUpdate, + path = [], + onWidgetChange, + onWidgetRemove, + dragState = null, +}: LayoutRendererProps) { + if (node.type === "leaf") { + const leafId = `leaf-${path.join("-") || "root"}`; + const leafIndex = path.length === 0 ? 0 : path[path.length - 1]; + const isDropTarget = dragState?.targetId === leafId ? dragState : null; + return ( + onWidgetChange(path, newWidget) + : undefined + } + onWidgetRemove={onWidgetRemove ? () => onWidgetRemove(path) : undefined} + dropTarget={isDropTarget} + /> + ); + } + + const isHorizontal = node.direction === "horizontal"; + + const handleResize = useCallback( + (childIndex: number, delta: number) => { + onUpdate((root) => adjustRatio(root, path, childIndex, delta)); + }, + [onUpdate, path], + ); + + const items: React.ReactNode[] = []; + + node.children.forEach((child, i) => { + const ratio = node.ratio[i] ?? 1 / node.children.length; + const childPath = [...path, i]; + + items.push( +
+ +
, + ); + + if (i < node.children.length - 1) { + items.push( + handleResize(i, delta)} + />, + ); + } + }); + + return ( +
+ {items} +
+ ); +} + +// --- Sortable leaf wrapper --- + +import { useSortable } from "@dnd-kit/react/sortable"; + +interface SortableWidgetLeafProps { + id: string; + index: number; + widgetKey: string; + store: LivestreamStore; + user?: string; + onWidgetChange?: (newWidget: string) => void; + onWidgetRemove?: () => void; + /** Non-null when this leaf is the current drop target. */ + dropTarget?: DragState | null; +} + +function SortableWidgetLeaf({ + id, + index, + widgetKey, + store, + user, + onWidgetChange, + onWidgetRemove, + dropTarget, +}: SortableWidgetLeafProps) { + const { ref, isDragging, handleRef } = useSortable({ id, index }); + + return ( + + ); +} + +/** + * Thin wrapper that renders the layout tree. The `DragDropProvider` and + * drag-event handling are owned by the parent (so the provider can wrap + * other draggable sources like the widget drawer too). Pass `dragState` + * down so leaves can render the split-preview overlay. + */ +export function DraggableLayoutRenderer({ + layout, + store, + user, + onUpdate, + onWidgetChange, + onWidgetRemove, + dragState = null, +}: { + layout: LayoutNode; + store: LivestreamStore; + user?: string; + onUpdate: (updater: (node: LayoutNode) => LayoutNode) => void; + onWidgetChange?: (path: number[], newWidget: string) => void; + onWidgetRemove?: (path: number[]) => void; + dragState?: DragState | null; +}) { + return ( + + ); +} + +/** Convert a leaf ID like "leaf-0-1-0" to a path array [0, 1, 0]. */ +export function idToPath(id: string): number[] | null { + if (!id.startsWith("leaf-")) return null; + const rest = id.slice(5); + if (rest === "root") return []; + const parts = rest.split("-").map(Number); + if (parts.some(isNaN)) return null; + return parts; +} + +/** Get the widget key at a given path in the tree. */ +export function getLeafWidget(root: LayoutNode, path: number[]): string | null { + if (path.length === 0) { + return root.type === "leaf" ? root.widget : null; + } + if (root.type !== "split") return null; + const [head, ...rest] = path; + return getLeafWidget(root.children[head], rest); +} + +/** + * Find a leaf's DOM element by its sortable ID. The sortable stores its + * element on the underlying draggable/droppable; we walk the document for + * the matching node. Cheaper alternatives exist (a Map) but this is + * fine for a small dashboard. + */ +export function findElementById(id: string): HTMLElement | null { + return document.querySelector(`[data-leaf-id="${id}"]`); +} + +/** + * dnd-kit's `Position` is a `ValueHistory` — it tracks `.current` (the + * latest pointer position) and `.initial` (where the drag started). It + * does not expose direct `.x`/`.y` properties, so we read `.current`. + */ +export function readPointer(operation: any): { x: number; y: number } | null { + const pos = operation?.position?.current; + if (!pos) return null; + if (typeof pos.x !== "number" || typeof pos.y !== "number") return null; + return { x: pos.x, y: pos.y }; +} diff --git a/js/web/src/components/dashboard/layout.ts b/js/web/src/components/dashboard/layout.ts new file mode 100644 index 00000000..6a683dfb --- /dev/null +++ b/js/web/src/components/dashboard/layout.ts @@ -0,0 +1,288 @@ +/** + * Layout data model for the dashboard control panel. + * + * The layout is a recursive tree where internal nodes are "splits" + * (horizontal or vertical containers with fractional ratios) and + * leaf nodes are "widgets" (identified by a string key into the + * widget registry). + * + * The entire tree serializes to/from JSON for localStorage persistence. + */ + +// --- Types --- + +export interface SplitNode { + type: "split"; + direction: "horizontal" | "vertical"; + /** Fractional sizes for each child. Must sum to ~1.0. */ + ratio: number[]; + children: LayoutNode[]; +} + +export interface LeafNode { + type: "leaf"; + /** Key into the widget registry. */ + widget: string; + /** Optional per-widget configuration. */ + config?: Record; +} + +export type LayoutNode = SplitNode | LeafNode; + +export interface LayoutPreset { + name: string; + layout: LayoutNode; +} + +// --- Default layout --- + +export const DEFAULT_LAYOUT: LayoutNode = { + type: "split", + direction: "horizontal", + ratio: [0.65, 0.35], + children: [ + { + type: "split", + direction: "vertical", + ratio: [0.6, 0.4], + children: [ + { type: "leaf", widget: "stream-monitor" }, + { type: "leaf", widget: "stream-health" }, + ], + }, + { + type: "split", + direction: "vertical", + ratio: [0.5, 0.3, 0.2], + children: [ + { type: "leaf", widget: "chat" }, + { type: "leaf", widget: "stream-info" }, + { type: "leaf", widget: "multistream" }, + ], + }, + ], +}; + +// --- Validation --- + +function isValidDirection(d: unknown): d is "horizontal" | "vertical" { + return d === "horizontal" || d === "vertical"; +} + +/** + * Validates and normalizes a parsed JSON value into a LayoutNode. + * Returns null if the value is not a valid layout. + */ +export function validateLayout(value: unknown): LayoutNode | null { + if (!value || typeof value !== "object") return null; + const obj = value as Record; + + if (obj.type === "leaf") { + if (typeof obj.widget !== "string") return null; + return { + type: "leaf", + widget: obj.widget, + config: + obj.config && typeof obj.config === "object" + ? (obj.config as Record) + : undefined, + }; + } + + if (obj.type === "split") { + if (!isValidDirection(obj.direction)) return null; + if (!Array.isArray(obj.ratio) || !Array.isArray(obj.children)) return null; + if (obj.ratio.length < 2 || obj.ratio.length !== obj.children.length) + return null; + + const children = obj.children + .map(validateLayout) + .filter((c): c is LayoutNode => c !== null); + + if (children.length < 2) return null; + + // Normalize ratios to sum to 1 + const total = obj.ratio.reduce( + (sum: number, r: unknown) => sum + (typeof r === "number" ? r : 0), + 0, + ); + const ratio = + total > 0 + ? (obj.ratio as number[]).map((r) => r / total) + : children.map(() => 1 / children.length); + + return { type: "split", direction: obj.direction, ratio, children }; + } + + return null; +} + +// --- Tree utilities --- + +/** Collect all widget keys used in the layout. */ +export function collectWidgets(node: LayoutNode): string[] { + if (node.type === "leaf") return [node.widget]; + return node.children.flatMap(collectWidgets); +} + +/** + * Update a leaf node at a given path (array of child indices). + * Returns a new tree with the leaf's widget key replaced. + */ +export function updateLeafAt( + root: LayoutNode, + path: number[], + widget: string, +): LayoutNode { + if (path.length === 0) { + if (root.type === "leaf") return { ...root, widget }; + return root; + } + if (root.type !== "split") return root; + + const [head, ...rest] = path; + const newChildren = root.children.map((child, i) => + i === head ? updateLeafAt(child, rest, widget) : child, + ); + return { ...root, children: newChildren }; +} + +/** + * Replace a leaf at the given path with a split node containing the leaf's + * original widget and a new source widget. Used for the "drop to split" + * interaction in the control panel: dropping a widget onto a leaf turns + * that leaf into a 50/50 split. + */ +export function replaceLeafWithSplit( + root: LayoutNode, + path: number[], + sourceWidget: string, + direction: "horizontal" | "vertical", + sourceFirst: boolean, +): LayoutNode { + if (path.length === 0) { + if (root.type !== "leaf") return root; + const original = root.widget; + return { + type: "split", + direction, + ratio: [0.5, 0.5], + children: sourceFirst + ? [ + { type: "leaf", widget: sourceWidget }, + { type: "leaf", widget: original }, + ] + : [ + { type: "leaf", widget: original }, + { type: "leaf", widget: sourceWidget }, + ], + }; + } + if (root.type !== "split") return root; + const [head, ...rest] = path; + return { + ...root, + children: root.children.map((child, i) => + i === head + ? replaceLeafWithSplit( + child, + rest, + sourceWidget, + direction, + sourceFirst, + ) + : child, + ), + }; +} + +/** + * Remove a leaf at the given path. Any split that ends up with fewer than + * two children is collapsed into its surviving child, so the tree always + * remains a valid layout (splits have >= 2 children). + * + * If the path points at the root leaf (path length 0), the root is + * returned unchanged — there's no sensible empty layout to produce. + */ +export function removeLeafAt(root: LayoutNode, path: number[]): LayoutNode { + if (path.length === 0) return root; + if (root.type !== "split") return root; + + const [head, ...rest] = path; + if (head < 0 || head >= root.children.length) return root; + + if (rest.length === 0) { + // Direct child of this split is the leaf to remove. + const newChildren = root.children.filter((_, i) => i !== head); + const newRatio = root.ratio.filter((_, i) => i !== head); + const total = newRatio.reduce((sum, r) => sum + r, 0); + const ratio = + total > 0 + ? newRatio.map((r) => r / total) + : newChildren.map(() => 1 / Math.max(newChildren.length, 1)); + + if (newChildren.length === 1) { + return newChildren[0]; + } + if (newChildren.length === 0) { + // Edge case: split had one child and we removed it. Return a + // placeholder blank so the caller still gets a valid tree. + return { type: "leaf", widget: "blank" }; + } + return { ...root, children: newChildren, ratio }; + } + + // Recurse into the child at `head`. The recursion may collapse that + // child to a single node, but as long as we still have 2+ children + // here, the parent split stays valid. + const newChildren = root.children.map((child, i) => + i === head ? removeLeafAt(child, rest) : child, + ); + return { ...root, children: newChildren }; +} + +/** + * Update the ratio of a split node at a given path. + */ +export function updateRatioAt( + root: LayoutNode, + path: number[], + newRatio: number[], +): LayoutNode { + if (path.length === 0) { + if (root.type === "split") return { ...root, ratio: newRatio }; + return root; + } + if (root.type !== "split") return root; + + const [head, ...rest] = path; + const newChildren = root.children.map((child, i) => + i === head ? updateRatioAt(child, rest, newRatio) : child, + ); + return { ...root, children: newChildren }; +} + +/** Assign unique IDs to each leaf for dnd-kit. Returns a flat list. */ +export interface LeafDescriptor { + id: string; + path: number[]; + widget: string; +} + +export function enumerateLeaves( + node: LayoutNode, + path: number[] = [], +): LeafDescriptor[] { + if (node.type === "leaf") { + return [ + { + id: `leaf-${path.join("-") || "root"}`, + path, + widget: node.widget, + }, + ]; + } + return node.children.flatMap((child, i) => + enumerateLeaves(child, [...path, i]), + ); +} diff --git a/js/web/src/components/dashboard/multistream-status.tsx b/js/web/src/components/dashboard/multistream-status.tsx new file mode 100644 index 00000000..cfc66c0b --- /dev/null +++ b/js/web/src/components/dashboard/multistream-status.tsx @@ -0,0 +1,213 @@ +import { cn } from "@/lib/utils"; +import type { LivestreamStore } from "@streamplace/core"; +import { Globe, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { usePDSAgent } from "../../lib/store/hooks"; + +interface MultistreamTarget { + uri: string; + record: { + [x: string]: unknown; + name?: string; + url?: string; + active: boolean; + createdAt: string; + }; + latestEvent?: { + status: string; + }; +} + +function getTargetName(target: MultistreamTarget): string { + if (target.record.name) return target.record.name; + if (target.record.url) { + try { + return new URL(target.record.url).host; + } catch { + return "Untitled Target"; + } + } + return "Untitled Target"; +} + +function getTargetHostname(target: MultistreamTarget): string | null { + if (!target.record.url) return null; + try { + return new URL(target.record.url).host.split(":")[0]; + } catch { + return null; + } +} + +function statusColor(target: MultistreamTarget): string { + if (!target.record.active) return "text-[var(--color-fg-muted)]"; + switch (target.latestEvent?.status) { + case "active": + return "text-green-400"; + case "error": + return "text-red-400"; + case "pending": + return "text-amber-400 animate-pulse"; + default: + return "text-[var(--color-fg-muted)]"; + } +} + +/** + * Lists multistream targets with active/inactive toggles and connection + * status. Port of MultistreamStatus from the RN app. + */ +export function MultistreamStatusWidget({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); + const agent = usePDSAgent(); + const [targets, setTargets] = useState([]); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState>(new Set()); + + const loadTargets = useCallback(async () => { + if (!agent) return; + try { + setLoading(true); + const response = await agent.place.stream.multistream.listTargets({ + limit: 50, + }); + setTargets(response.data.targets as unknown as MultistreamTarget[]); + } catch (error) { + console.error("Failed to load multistream targets:", error); + setTargets([]); + } finally { + setLoading(false); + } + }, [agent]); + + const toggleTarget = useCallback( + async (target: MultistreamTarget, newActive: boolean) => { + if (!agent) return; + try { + setToggling((prev) => new Set(prev).add(target.uri)); + await agent.place.stream.multistream.putTarget({ + multistreamTarget: { + ...target.record, + $type: "place.stream.multistream.target" as const, + active: newActive, + } as any, + rkey: target.uri.split("/").pop() || "", + }); + await loadTargets(); + } catch (error) { + console.error("Failed to toggle multistream target:", error); + } finally { + setToggling((prev) => { + const next = new Set(prev); + next.delete(target.uri); + return next; + }); + } + }, + [agent, loadTargets], + ); + + useEffect(() => { + loadTargets(); + }, [loadTargets]); + + if (loading && targets.length === 0) { + return ( +
+
+ + {t("loading-multistream", { + defaultValue: "Loading multistream…", + })} +
+
+ ); + } + + if (targets.length === 0) { + return ( +
+
+ + {t("no-multistream-targets", { + defaultValue: "No multistream targets configured", + })} +
+
+ ); + } + + return ( +
+
+ +

+ {t("multistream", { defaultValue: "Multistream" })} +

+ {loading && ( + + )} +
+ +
+ {targets.map((target) => { + const name = getTargetName(target); + const hostname = getTargetHostname(target); + const isToggling = toggling.has(target.uri); + + return ( +
+
+
+ {name} + {target.record.name && hostname && ( + + {hostname} + + )} +
+ {target.latestEvent && ( +
+ {target.latestEvent.status} +
+ )} +
+ + +
+ ); + })} +
+
+ ); +} diff --git a/js/web/src/components/dashboard/problems.tsx b/js/web/src/components/dashboard/problems.tsx new file mode 100644 index 00000000..4d86c004 --- /dev/null +++ b/js/web/src/components/dashboard/problems.tsx @@ -0,0 +1,105 @@ +import type { LivestreamStore } from "@streamplace/core"; +import { AlertCircle, AlertTriangle, Info, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useStore } from "zustand"; + +interface Problem { + code: string; + severity: string; + message: string; + link?: string; +} + +function ProblemIcon({ severity }: { severity: string }) { + switch (severity) { + case "error": + return ; + case "warning": + return ; + case "info": + return ; + default: + return ; + } +} + +function severityBg(severity: string): string { + switch (severity) { + case "error": + return "bg-red-500/10 border-red-500/20"; + case "warning": + return "bg-amber-500/10 border-amber-500/20"; + case "info": + return "bg-blue-500/10 border-blue-500/20"; + default: + return "bg-[var(--color-bg-elevated)] border-[var(--color-border)]"; + } +} + +/** + * Displays stream optimization warnings from the LivestreamStore. + * Renders as a card widget for the dashboard control panel. + */ +export function ProblemsWidget({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); + const problems = useStore(store, (s) => s.problems); + const [dismissed, setDismissed] = useState(false); + + if (problems.length === 0 || dismissed) { + return null; + } + + return ( +
+
+
+ +

+ {t("stream-optimization", { defaultValue: "Stream Optimization" })} +

+ {problems.length > 0 && ( + + {problems.length} + + )} +
+ +
+ +
+ {problems.map((problem: Problem) => ( +
+ +
+
{problem.code}
+
+ {problem.message} +
+ {problem.link && ( + + Learn more + + )} +
+
+ ))} +
+
+ ); +} diff --git a/js/web/src/components/dashboard/registry.tsx b/js/web/src/components/dashboard/registry.tsx new file mode 100644 index 00000000..73535afa --- /dev/null +++ b/js/web/src/components/dashboard/registry.tsx @@ -0,0 +1,103 @@ +import type { LivestreamStore } from "@streamplace/core"; +import { + Activity, + AlertTriangle, + Globe, + MessageSquare, + Radio, + SquareDashed, + Video, + type LucideIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; +import { useTranslation } from "react-i18next"; +import { ChatPanelWidget } from "./chat-panel"; +import { MultistreamStatusWidget } from "./multistream-status"; +import { ProblemsWidget } from "./problems"; +import { StreamHealthWidget } from "./stream-health"; +import { StreamInfoWidget } from "./stream-info"; +import { StreamMonitorWidget } from "./stream-monitor"; + +export interface WidgetMeta { + component: ComponentType<{ store: LivestreamStore; user?: string }>; + title: string; + icon: LucideIcon; + color: string; + minWidth: number; + minHeight: number; +} + +/** Placeholder shown for empty slots after deleting a widget. */ +function BlankWidget() { + const { t } = useTranslation("settings"); + return ( +
+ {t("drop-widget-here", { defaultValue: "Drop a widget here" })} +
+ ); +} + +export const WIDGET_REGISTRY: Record = { + blank: { + component: BlankWidget, + title: "Empty", + icon: SquareDashed, + color: "#6b7280", + minWidth: 120, + minHeight: 80, + }, + "stream-monitor": { + component: StreamMonitorWidget, + title: "Stream Monitor", + icon: Video, + color: "#3b82f6", + minWidth: 320, + minHeight: 180, + }, + "stream-health": { + component: StreamHealthWidget, + title: "Stream Health", + icon: Activity, + color: "#22c55e", + minWidth: 240, + minHeight: 200, + }, + chat: { + component: ChatPanelWidget, + title: "Chat", + icon: MessageSquare, + color: "#ec4899", + minWidth: 280, + minHeight: 300, + }, + "stream-info": { + component: StreamInfoWidget, + title: "Stream Info", + icon: Radio, + color: "#f97316", + minWidth: 280, + minHeight: 400, + }, + multistream: { + component: MultistreamStatusWidget, + title: "Multistream", + icon: Globe, + color: "#06b6d4", + minWidth: 240, + minHeight: 120, + }, + problems: { + component: ProblemsWidget, + title: "Problems", + icon: AlertTriangle, + color: "#f59e0b", + minWidth: 240, + minHeight: 100, + }, +}; + +export const WIDGET_KEYS = Object.keys(WIDGET_REGISTRY); + +export function getWidgetMeta(key: string): WidgetMeta | undefined { + return WIDGET_REGISTRY[key]; +} diff --git a/js/web/src/components/dashboard/resize-handle.tsx b/js/web/src/components/dashboard/resize-handle.tsx new file mode 100644 index 00000000..c349567e --- /dev/null +++ b/js/web/src/components/dashboard/resize-handle.tsx @@ -0,0 +1,78 @@ +import { useCallback, useRef } from "react"; + +interface ResizeHandleProps { + direction: "horizontal" | "vertical"; + /** Called with the delta as a fraction of the parent's size. */ + onResize: (delta: number) => void; +} + +/** + * Draggable divider between two split children. + * Adjusts the ratio between adjacent children when dragged. + */ +export function ResizeHandle({ direction, onResize }: ResizeHandleProps) { + const startRef = useRef<{ pos: number; size: number } | null>(null); + const containerRef = useRef(null); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const el = containerRef.current; + if (!el) return; + const parent = el.parentElement; + if (!parent) return; + + const isHorizontal = direction === "horizontal"; + const pos = isHorizontal ? e.clientX : e.clientY; + const size = isHorizontal ? parent.clientWidth : parent.clientHeight; + startRef.current = { pos, size }; + + const handlePointerMove = (ev: PointerEvent) => { + if (!startRef.current) return; + const currentPos = isHorizontal ? ev.clientX : ev.clientY; + const delta = + (currentPos - startRef.current.pos) / startRef.current.size; + onResize(delta); + }; + + const handlePointerUp = () => { + startRef.current = null; + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + document.body.style.cursor = isHorizontal ? "col-resize" : "row-resize"; + document.body.style.userSelect = "none"; + }, + [direction, onResize], + ); + + const isHorizontal = direction === "horizontal"; + + return ( +
+
+
+ ); +} diff --git a/js/web/src/components/dashboard/stream-health.tsx b/js/web/src/components/dashboard/stream-health.tsx new file mode 100644 index 00000000..b920256b --- /dev/null +++ b/js/web/src/components/dashboard/stream-health.tsx @@ -0,0 +1,506 @@ +import { cn } from "@/lib/utils"; +import type { LivestreamStore } from "@streamplace/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { DashboardMetricsPoint } from "./dashboard-metrics"; +import { DASHBOARD_RANGES, useDashboardMetrics } from "./dashboard-metrics"; + +type MetricGroup = "video" | "audience"; +type AxisId = "primary" | "secondary"; +type MetricId = "bitrate" | "viewers" | "segmentTiming" | "chatRate" | "fps"; + +interface MetricConfig { + id: MetricId; + label: string; + group: MetricGroup; + /** Which y-axis this metric renders against. The audience chart + * ignores this and uses a single axis. */ + yAxisId: AxisId; + color: string; + format: (v: number) => string; +} + +const METRICS: Record = { + bitrate: { + id: "bitrate", + label: "Bitrate", + group: "video", + yAxisId: "primary", + color: "#22c55e", + format: (v) => `${(v / 1000).toFixed(2)} Mbps`, + }, + segmentTiming: { + id: "segmentTiming", + label: "Segment Δ", + group: "video", + yAxisId: "primary", + color: "#f59e0b", + format: (v) => `${Math.round(v)} ms`, + }, + fps: { + id: "fps", + label: "FPS", + group: "video", + yAxisId: "secondary", + color: "#ec4899", + format: (v) => `${Math.round(v)} fps`, + }, + viewers: { + id: "viewers", + label: "Viewers", + group: "audience", + yAxisId: "primary", + color: "#a78bfa", + format: (v) => `${Math.round(v)}`, + }, + chatRate: { + id: "chatRate", + label: "Chat rate", + group: "audience", + yAxisId: "primary", + color: "#06b6d4", + format: (v) => `${v.toFixed(1)} msg/min`, + }, +}; + +const METRIC_KEYS = Object.keys(METRICS) as MetricId[]; + +/** + * Compact number formatter for chart axis labels: 1500 → "1.5k", + * 2400 → "2.4k", 6000 → "6k", 12000 → "12k", 2_500_000 → "2.5M". Anything + * under 1000 is shown as-is. The unit is implicit — viewers read the chips + * to know whether the axis is kbps, ms, msg/min, etc. + */ +function compactNumber(v: number): string { + const abs = Math.abs(v); + if (abs >= 1_000_000) { + return `${(v / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`; + } + if (abs >= 1_000) { + return `${(v / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`; + } + return v.toString(); +} + +type ConnectionQuality = "good" | "degraded" | "poor" | "pre-live"; + +function getConnectionQuality( + segmentTiming: number, + hasLivestream: boolean, +): ConnectionQuality { + if (!hasLivestream) return "pre-live"; + if (segmentTiming === 0) return "poor"; + if (segmentTiming <= 1500) return "good"; + if (segmentTiming <= 3000) return "degraded"; + return "poor"; +} + +/** + * Stream Health widget. All heavy lifting (metric sampling, history + * buffer, range management) lives in `DashboardMetricsProvider` higher + * in the tree — this component only reads the pre-computed data and + * renders the charts, chips, and footer. + */ +export function StreamHealthWidget({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); + const { history, range, setRange, hasLivestream } = useDashboardMetrics(); + + const [activeMetrics, setActiveMetrics] = useState>( + () => new Set(["bitrate"]), + ); + + // Track the chart container's aspect ratio. The two charts split along + // the longest axis — landscape → vertical split (left/right), + // portrait → horizontal split (top/bottom). + const chartContainerRef = useRef(null); + const [isLandscape, setIsLandscape] = useState(true); + useEffect(() => { + if (!chartContainerRef.current) return; + const update = () => { + if (!chartContainerRef.current) return; + const { width, height } = + chartContainerRef.current.getBoundingClientRect(); + setIsLandscape(width >= height); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(chartContainerRef.current); + return () => ro.disconnect(); + }, []); + + const lastPoint = history[history.length - 1] ?? emptyPoint(Date.now()); + const connectionQuality = useMemo( + () => getConnectionQuality(lastPoint.segmentTiming, hasLivestream), + [hasLivestream, lastPoint.segmentTiming], + ); + + const qualityStyles = { + good: { dot: "bg-green-400", text: "text-green-400" }, + degraded: { dot: "bg-amber-400", text: "text-amber-400" }, + poor: { dot: "bg-red-400", text: "text-red-400" }, + "pre-live": { dot: "bg-blue-400", text: "text-blue-400" }, + }[connectionQuality]; + const qualityLabel = { + good: t("connection-excellent", { defaultValue: "Excellent" }), + degraded: t("connection-good", { defaultValue: "Good" }), + poor: t("connection-poor", { defaultValue: "Poor" }), + "pre-live": t("connection-pre-live", { defaultValue: "Not live" }), + }[connectionQuality]; + + const toggleMetric = (id: MetricId) => { + setActiveMetrics((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const stats = useMemo(() => { + const out: Record = + {}; + for (const id of activeMetrics) { + const values = history.map((d) => d[id]); + const current = values[values.length - 1] ?? 0; + const avg = + values.length > 0 + ? values.reduce((a, b) => a + b, 0) / values.length + : 0; + const peak = values.length > 0 ? Math.max(...values) : 0; + out[id] = { current, avg, peak }; + } + return out; + }, [history, activeMetrics]); + + const activeVideoIds = METRIC_KEYS.filter( + (id) => activeMetrics.has(id) && METRICS[id].group === "video", + ); + const activeAudienceIds = METRIC_KEYS.filter( + (id) => activeMetrics.has(id) && METRICS[id].group === "audience", + ); + + const showVideo = activeVideoIds.length > 0; + const showAudience = activeAudienceIds.length > 0; + + return ( +
+ {/* Header */} +
+
+

+ {t("stream-health", { defaultValue: "Stream Health" })} +

+
+ + {qualityLabel} + +
+
+ {DASHBOARD_RANGES.map((r) => ( + + ))} +
+
+ + {/* Chart container. When both video and audience metrics are + active, splits in half along the longest axis. When only one + set is active, that chart fills the available space. */} +
+ {showVideo && ( + + )} + {showAudience && ( + + )} + {!showVideo && !showAudience && ( +
+ {t("select-metric", { defaultValue: "Click a metric to overlay" })} +
+ )} +
+ + {/* Metric chips — each shows its current value always, and toggles + that metric in/out of the chart when clicked. */} +
+ {METRIC_KEYS.map((id) => { + const metric = METRICS[id]; + const isActive = activeMetrics.has(id); + const current = lastPoint[id]; + return ( + + ); + })} +
+ + {/* Footer stats — avg + peak per active metric (current is in the chip) */} + {activeMetrics.size > 0 && ( +
+ {METRIC_KEYS.filter((id) => activeMetrics.has(id)).map((id) => { + const metric = METRICS[id]; + const s = stats[id]; + if (!s) return null; + return ( +
+
+ + {metric.label} + + + avg {metric.format(s.avg)} + + + peak {metric.format(s.peak)} + +
+ ); + })} +
+ )} +
+ ); +} + +interface ChartProps { + history: DashboardMetricsPoint[]; + activeIds: MetricId[]; +} + +function VideoChart({ history, activeIds }: ChartProps) { + const { t } = useTranslation("common"); + const hasSecondary = activeIds.some( + (id) => METRICS[id].yAxisId === "secondary", + ); + return ( +
+
+ {t("video", { defaultValue: "Video" })} +
+ + + + + new Date(t).toLocaleTimeString([], { + minute: "2-digit", + second: "2-digit", + }) + } + tick={{ fontSize: 9, fill: "var(--color-fg-muted)" }} + stroke="var(--color-border)" + minTickGap={32} + /> + + {hasSecondary && ( + + )} + new Date(t as number).toLocaleTimeString()} + formatter={(value, name) => { + const id = name as MetricId; + const metric = METRICS[id]; + if (!metric) return [value as string, name as string]; + return [metric.format(value as number), metric.label]; + }} + /> + {activeIds.map((id) => { + const metric = METRICS[id]; + return ( + + ); + })} + + +
+ ); +} + +function AudienceChart({ history, activeIds }: ChartProps) { + const { t } = useTranslation("common"); + return ( +
+
+ {t("audience", { defaultValue: "Audience" })} +
+ + + + + new Date(t).toLocaleTimeString([], { + minute: "2-digit", + second: "2-digit", + }) + } + tick={{ fontSize: 9, fill: "var(--color-fg-muted)" }} + stroke="var(--color-border)" + minTickGap={32} + /> + + new Date(t as number).toLocaleTimeString()} + formatter={(value, name) => { + const id = name as MetricId; + const metric = METRICS[id]; + if (!metric) return [value as string, name as string]; + return [metric.format(value as number), metric.label]; + }} + /> + {activeIds.map((id) => { + const metric = METRICS[id]; + return ( + + ); + })} + + +
+ ); +} + +function emptyPoint(t: number): DashboardMetricsPoint { + return { t, bitrate: 0, viewers: 0, segmentTiming: 0, chatRate: 0, fps: 0 }; +} diff --git a/js/web/src/components/dashboard/stream-info.tsx b/js/web/src/components/dashboard/stream-info.tsx new file mode 100644 index 00000000..dde135bc --- /dev/null +++ b/js/web/src/components/dashboard/stream-info.tsx @@ -0,0 +1,736 @@ +import { CONTENT_WARNINGS } from "@/lib/content-warnings"; +import { useStore as useGlobalStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import type { LivestreamStore } from "@streamplace/core"; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ImagePlus, + Loader2, + Radio, + Square, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import type { PlaceStreamLivestream } from "streamplace"; +import { useStore } from "zustand"; +import { + usePDSAgent, + useStreamplaceUrl, + useUserProfile, +} from "../../lib/store/hooks"; +import { Admonition } from "../ui/admonition"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { ActivityPicker } from "./activity-picker"; +import { useDashboardStore } from "./dashboard-store-context"; + +const LANGUAGES = [ + { code: "en", label: "English" }, + { code: "es", label: "Español" }, + { code: "fr", label: "Français" }, + { code: "de", label: "Deutsch" }, + { code: "pt", label: "Português" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "zh", label: "中文" }, + { code: "ar", label: "العربية" }, + { code: "ru", label: "Русский" }, +]; + +const LANG_TAG_PREFIX = "lang:"; + +/** + * Stream info and control widget. Title, activity, tags, thumbnail, + * content-warnings quick-edit, options, and create/update/end + * livestream. The full metadata editor (with content rights and + * distribution) lives at /dashboard/stream. + */ +export function StreamInfoWidget({ store }: { store: LivestreamStore }) { + const { t } = useTranslation("common"); + const agent = usePDSAgent(); + const url = useStreamplaceUrl(); + const profile = useUserProfile(); + const liveStore = useDashboardStore(); + + const livestream = useStore(store, (s) => s.livestream); + const segment = useStore(store, (s) => s.segment); + + const isLive = !!segment; + const hasLivestream = !!livestream; + const isEnded = livestream?.record?.endedAt !== undefined; + + const [page, setPage] = useState<"info" | "contentWarnings">("info"); + + // Form state + const [title, setTitle] = useState(""); + const [activity, setActivity] = useState< + PlaceStreamLivestream.Record["activity"] | undefined + >(undefined); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); + const [thumbnail, setThumbnail] = useState(); + const [thumbnailPreview, setThumbnailPreview] = useState< + string | undefined + >(); + const [createPost, setCreatePost] = useState(true); + const [idleTimeout, setIdleTimeout] = useState(true); + const [loading, setLoading] = useState(false); + const [endingLivestream, setEndingLivestream] = useState(false); + const initializedRef = useRef(false); + + // Initialize form from existing livestream record + useEffect(() => { + if (!livestream || initializedRef.current) return; + initializedRef.current = true; + if (livestream.record.title) setTitle(livestream.record.title); + if (livestream.record.activity) { + setActivity( + livestream.record.activity as PlaceStreamLivestream.Record["activity"], + ); + } + if (livestream.record.tags) setTags(livestream.record.tags as string[]); + if (typeof livestream.record.idleTimeoutSeconds === "number") { + setIdleTimeout(livestream.record.idleTimeoutSeconds > 0); + } + }, [livestream]); + + // Reset ending state when livestream ends + useEffect(() => { + if (livestream && livestream.record.endedAt !== undefined) { + setEndingLivestream(false); + } + }, [livestream]); + + const canSubmit = title.trim().length > 0 && !loading; + const canEnd = hasLivestream && !isEnded && !endingLivestream; + + const buttonText = useMemo(() => { + if (loading) return t("loading", { defaultValue: "Loading…" }); + if (!isLive) + return t("waiting-for-stream", { defaultValue: "Waiting for stream…" }); + if (!hasLivestream || isEnded) + return t("start-livestream", { defaultValue: "Start Livestream" }); + return t("update-livestream", { defaultValue: "Update Livestream" }); + }, [loading, isLive, hasLivestream, isEnded, t]); + + // Thumbnail handling + const handleThumbnailSelect = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + setThumbnail(file); + setThumbnailPreview(URL.createObjectURL(file)); + } + }; + input.click(); + }, []); + + const handleThumbnailRemove = useCallback(() => { + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); + setThumbnail(undefined); + setThumbnailPreview(undefined); + }, [thumbnailPreview]); + + // Tag handling + const handleAddTag = useCallback( + (tag: string) => { + const trimmed = tag.trim(); + if (trimmed && !tags.includes(trimmed) && tags.length < 10) { + setTags([...tags, trimmed]); + } + setTagInput(""); + }, + [tags], + ); + + const handleRemoveTag = useCallback( + (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }, + [tags], + ); + + // Create/Update + const handleSubmit = useCallback(async () => { + if (!agent || !agent.did || !canSubmit) return; + setLoading(true); + + try { + const record: PlaceStreamLivestream.Record = { + $type: "place.stream.livestream", + title: title.trim(), + url: `${url}/${agent.did}`, + createdAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + idleTimeoutSeconds: idleTimeout ? 300 : 0, + activity, + tags: tags.length > 0 ? tags : undefined, + }; + + // Upload thumbnail if provided + if (thumbnail) { + try { + const uploaded = await agent.uploadBlob(thumbnail); + if (uploaded.success) { + record.thumb = uploaded.data.blob; + } + } catch (e) { + console.error("Thumbnail upload failed:", e); + } + } + + if (!hasLivestream || isEnded) { + // Create new livestream + const result = await agent.place.stream.live.startLivestream({ + livestream: record, + streamer: agent.did, + createBlueskyPost: createPost, + }); + if (!result.success) throw new Error("Failed to start livestream"); + toast.success( + t("livestream-announced", { defaultValue: "Livestream announced" }), + ); + } else { + // Update existing + const rkey = livestream!.uri.split("/").pop(); + if (!rkey) throw new Error("No rkey"); + // Preserve existing thumb if no new one + if (!record.thumb && livestream!.record.thumb) { + record.thumb = livestream!.record.thumb; + } + record.post = livestream!.record.post; + await agent.com.atproto.repo.putRecord({ + repo: agent.did, + collection: "place.stream.livestream", + rkey, + record, + }); + toast.success( + t("livestream-updated", { defaultValue: "Livestream updated" }), + ); + } + } catch (error) { + console.error("Error with livestream:", error); + toast.error(String(error).slice(0, 200)); + } finally { + setLoading(false); + } + }, [ + agent, + url, + title, + activity, + tags, + thumbnail, + createPost, + idleTimeout, + canSubmit, + hasLivestream, + isEnded, + livestream, + t, + ]); + + const language = useMemo(() => { + const langTag = tags.find((t) => t.startsWith(LANG_TAG_PREFIX)); + if (langTag) { + const code = langTag.slice(LANG_TAG_PREFIX.length); + return LANGUAGES.find((l) => l.code === code)?.label || null; + } + return null; + }, [tags]); + + // End livestream + const handleEnd = useCallback(async () => { + if (!agent || !canEnd) return; + setEndingLivestream(true); + try { + await agent.place.stream.live.stopLivestream({}); + toast.success( + t("livestream-ended", { defaultValue: "Livestream ended" }), + ); + } catch (error) { + console.error("Error ending livestream:", error); + toast.error( + t("failed-to-end", { defaultValue: "Failed to end livestream" }), + ); + setEndingLivestream(false); + } + }, [agent, canEnd, t]); + + if (!agent) { + return ( +
+ {t("login-required", { + defaultValue: "Please log in to manage streams.", + })} +
+ ); + } + + if (page === "contentWarnings") { + return ( +
+ +

+ {t("content-warnings", { defaultValue: "Content Warnings" })} +

+

+ {t("content-warnings-info", { + defaultValue: + "You're required to flag your stream with themes that viewers may want a heads-up about.", + })} +

+ +
+ ); + } + + return ( +
+
+ {/* Title */} +
+ +