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 { place } 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< place.stream.livestream.Main["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); 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 place.stream.livestream.Main["activity"], ); } if (livestream.record.tags) setTags(livestream.record.tags as string[]); if (typeof livestream.record.idleTimeoutSeconds === "number") { setIdleTimeout(livestream.record.idleTimeoutSeconds > 0); } }, [livestream]); 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]); 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]); 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], ); const handleSubmit = useCallback(async () => { if (!agent || !agent.did || !canSubmit) return; setLoading(true); try { const record = { $type: "place.stream.livestream", title: title.trim(), url: `${url}/${agent.did}` as any, createdAt: new Date().toISOString() as any, lastSeenAt: new Date().toISOString() as any, idleTimeoutSeconds: idleTimeout ? 300 : 0, activity, tags: tags.length > 0 ? tags : undefined, } as any; if (thumbnail) { try { const uploaded = await agent.uploadBlob(thumbnail); if (uploaded.success) { (record as any).thumb = uploaded.data.blob; } } catch (e) { console.error("Thumbnail upload failed:", e); } } if (!hasLivestream || isEnded) { const result = await agent.client.call( place.stream.live.startLivestream, { livestream: record, streamer: agent.did as any, createBlueskyPost: createPost, }, ); toast.success( t("livestream-announced", { defaultValue: "Livestream announced" }), ); } else { const rkey = livestream!.uri.split("/").pop(); if (!rkey) throw new Error("No rkey"); // Keep existing thumbnail on updates when no new one was selected 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.client.call(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 if it has themes that viewers may want a heads-up about.", })}

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