fix: manual streams
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Kieran 2024-03-12 22:42:58 +00:00
parent 9bfed646d1
commit 345c1d58bf
8 changed files with 169 additions and 46 deletions

View File

@ -1,20 +1,21 @@
import "./index.css"; import "./index.css";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { NostrEvent } from "@snort/system"; import { EventKind, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { extractStreamInfo, findTag } from "@/utils"; import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { StreamState } from "@/const"; import { GOAL, StreamState, defaultRelays } from "@/const";
import { DefaultButton } from "@/element/buttons"; import { DefaultButton } from "@/element/buttons";
import Pill from "@/element/pill"; import Pill from "@/element/pill";
import { NewGoalDialog } from "./new-goal";
import { StreamInput } from "./input"; import { StreamInput } from "./input";
import { GoalSelector } from "./goal-selector"; import { GoalSelector } from "./goal-selector";
import GameDatabase, { GameInfo } from "@/service/game-database"; import GameDatabase, { GameInfo } from "@/service/game-database";
import CategoryInput from "./category-input"; import CategoryInput from "./category-input";
import { FileUploader } from "@/element/file-uploader";
import AmountInput from "@/element/amount-input";
export interface StreamEditorProps { export interface StreamEditorProps {
ev?: NostrEvent; ev?: NostrEvent;
@ -42,10 +43,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [contentWarning, setContentWarning] = useState(false); const [contentWarning, setContentWarning] = useState(false);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const [goal, setGoal] = useState<string>(); const [goal, setGoal] = useState<string>();
const [goalName, setGoalName] = useState("");
const [goalAmount, setGoalMount] = useState(0);
const [game, setGame] = useState<GameInfo>(); const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>(); const [gameId, setGameId] = useState<string>();
const login = useLogin(); const login = useLogin();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const system = useContext(SnortContext);
useEffect(() => { useEffect(() => {
const { gameInfo, gameId, title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } = const { gameInfo, gameId, title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
@ -88,12 +92,24 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
async function publishStream() { async function publishStream() {
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
let thisGoal = goal;
if (!goal && goalName && goalAmount) {
const goalEvent = await pub.generic(eb => {
return eb
.kind(GOAL)
.tag(["amount", String(goalAmount * 1000)])
.tag(["relays", ...Object.keys(defaultRelays)])
.content(goalName);
});
await system.BroadcastEvent(goalEvent);
thisGoal = goalEvent.id;
}
const evNew = await pub.generic(eb => { const evNew = await pub.generic(eb => {
const now = unixNow(); const now = unixNow();
const dTag = findTag(ev, "d") ?? now.toString(); const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? now.toString(); const starts = start ?? now.toString();
const ends = findTag(ev, "ends") ?? now.toString(); const ends = findTag(ev, "ends") ?? now.toString();
eb.kind(30311) eb.kind(EventKind.LiveEvent)
.tag(["d", dTag]) .tag(["d", dTag])
.tag(["title", title]) .tag(["title", title])
.tag(["summary", summary]) .tag(["summary", summary])
@ -115,8 +131,8 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
if (contentWarning) { if (contentWarning) {
eb.tag(["content-warning", "nsfw"]); eb.tag(["content-warning", "nsfw"]);
} }
if (goal && goal.length > 0) { if (thisGoal && thisGoal.length > 0) {
eb.tag(["goal", goal]); eb.tag(["goal", thisGoal]);
} }
if (gameId) { if (gameId) {
eb.tag(["t", gameId]); eb.tag(["t", gameId]);
@ -161,11 +177,10 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
)} )}
{(options?.canSetImage ?? true) && ( {(options?.canSetImage ?? true) && (
<StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}> <StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}>
{image && <img src={image} className="mb-2 aspect-video object-cover rounded-xl" />}
<div className="flex gap-2"> <div className="flex gap-2">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} /> <input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
<DefaultButton> <FileUploader onResult={v => setImage(v ?? "")} />
<FormattedMessage defaultMessage="Upload" />
</DefaultButton>
</div> </div>
</StreamInput> </StreamInput>
)} )}
@ -180,9 +195,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
{(options?.canSetStatus ?? true) && ( {(options?.canSetStatus ?? true) && (
<> <>
<StreamInput label={<FormattedMessage defaultMessage="Status" />}> <StreamInput label={<FormattedMessage defaultMessage="Status" />}>
<div className="flex gap-2"> <div className="flex gap-2 uppercase">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => ( {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}> <Pill selected={status === v} onClick={() => setStatus(v)} key={v}>
{v} {v}
</Pill> </Pill>
))} ))}
@ -218,7 +233,19 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
<StreamInput label={<FormattedMessage defaultMessage="Goal" />}> <StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<GoalSelector goal={goal} onGoalSelect={setGoal} /> <GoalSelector goal={goal} onGoalSelect={setGoal} />
<NewGoalDialog /> {!goal && (
<div className="flex gap-2">
<input
type="text"
placeholder={formatMessage({
defaultMessage: "Goal Name",
})}
value={goalName}
onChange={e => setGoalName(e.target.value)}
/>
<AmountInput onChange={setGoalMount} />
</div>
)}
</div> </div>
</StreamInput> </StreamInput>
)} )}

View File

@ -25,7 +25,7 @@ interface StatSlot {
} }
export default function StreamSummary({ link, preload }: { link: NostrLink; preload?: NostrEvent }) { export default function StreamSummary({ link, preload }: { link: NostrLink; preload?: NostrEvent }) {
const ev = useCurrentStreamFeed(link, true, preload); const ev = useCurrentStreamFeed(link, true, preload ? ({ ...preload, relays: [] } as TaggedNostrEvent) : undefined);
const thisLink = ev ? NostrLink.fromEvent(ev) : undefined; const thisLink = ev ? NostrLink.fromEvent(ev) : undefined;
const data = useReactions( const data = useReactions(
`live:${link?.id}:${link?.author}:reactions`, `live:${link?.id}:${link?.author}:reactions`,
@ -148,7 +148,7 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h1>{title}</h1> <h2>{title}</h2>
{summary && <p>{summary}</p>} {summary && <p>{summary}</p>}
<div className="flex gap-1"> <div className="flex gap-1">
<StatePill state={status as StreamState} /> <StatePill state={status as StreamState} />

View File

@ -6,7 +6,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "@/hooks/emoji"; import { useUserEmojiPacks } from "@/hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "@/const"; import { MUTED, USER_CARDS, USER_EMOJIS } from "@/const";
import type { Tags } from "@/types"; import type { Tags } from "@/types";
import { getPublisher, Login } from "@/login"; import { getPublisher, getSigner, Login } from "@/login";
export function useLogin() { export function useLogin() {
const session = useSyncExternalStore( const session = useSyncExternalStore(
@ -19,6 +19,9 @@ export function useLogin() {
publisher: () => { publisher: () => {
return getPublisher(session); return getPublisher(session);
}, },
signer: () => {
return getSigner(session);
},
}; };
} }

View File

@ -132,12 +132,19 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
} }
export function getPublisher(session: LoginSession) { export function getPublisher(session: LoginSession) {
const signer = getSigner(session);
if (signer) {
return new EventPublisher(signer, session.pubkey);
}
}
export function getSigner(session: LoginSession) {
switch (session?.type) { switch (session?.type) {
case LoginType.Nip7: { case LoginType.Nip7: {
return new EventPublisher(new Nip7Signer(), session.pubkey); return new Nip7Signer();
} }
case LoginType.PrivateKey: { case LoginType.PrivateKey: {
return new EventPublisher(new PrivateKeySigner(unwrap(session.privateKey)), session.pubkey); return new PrivateKeySigner(unwrap(session.privateKey));
} }
} }
} }

View File

@ -26,7 +26,7 @@ export function DashboardZapColumn({
const zapSum = sortedZaps.reduce((acc, v) => acc + v.amount, 0); const zapSum = sortedZaps.reduce((acc, v) => acc + v.amount, 0);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2">
<DashboardCard className="flex flex-col gap-2"> <DashboardCard className="flex flex-col gap-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">

View File

@ -2,9 +2,9 @@ import { LiveChat } from "@/element/live-chat";
import LiveVideoPlayer from "@/element/live-video-player"; import LiveVideoPlayer from "@/element/live-video-player";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed"; import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { extractStreamInfo } from "@/utils"; import { extractStreamInfo } from "@/utils";
import { NostrLink } from "@snort/system"; import { EventExt, NostrEvent, NostrLink } from "@snort/system";
import { useReactions } from "@snort/system-react"; import { SnortContext, useReactions } from "@snort/system-react";
import { useEffect, useMemo, useState } from "react"; import { Suspense, lazy, useContext, useEffect, useMemo, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import { StreamTimer } from "@/element/stream-time"; import { StreamTimer } from "@/element/stream-time";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP, StreamState } from "@/const"; import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP, StreamState } from "@/const";
@ -21,9 +21,13 @@ import StreamKey from "@/element/provider/nostr/stream-key";
import { DefaultProvider, NostrStreamProvider, StreamProviderInfo } from "@/providers"; import { DefaultProvider, NostrStreamProvider, StreamProviderInfo } from "@/providers";
import { ExternalLink } from "@/element/external-link"; import { ExternalLink } from "@/element/external-link";
import BalanceTimeEstimate from "@/element/balance-time-estimate"; import BalanceTimeEstimate from "@/element/balance-time-estimate";
import { DefaultButton } from "@/element/buttons"; import { WarningButton } from "@/element/buttons";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import AccountTopup from "@/element/provider/nostr/topup"; import AccountTopup from "@/element/provider/nostr/topup";
import classNames from "classnames";
import ManualStream from "./manual-stream";
import { unixNow } from "@snort/shared";
const StreamSummary = lazy(() => import("@/element/summary-chart"));
export function DashboardForLink({ link }: { link: NostrLink }) { export function DashboardForLink({ link }: { link: NostrLink }) {
const streamEvent = useCurrentStreamFeed(link, true); const streamEvent = useCurrentStreamFeed(link, true);
@ -32,6 +36,8 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined; const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
const { stream, status, image, participants, service } = extractStreamInfo(streamEvent); const { stream, status, image, participants, service } = extractStreamInfo(streamEvent);
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const isMyManual = streamEvent?.pubkey === login?.pubkey;
const system = useContext(SnortContext);
const provider = useMemo(() => (service ? new NostrStreamProvider("", service) : DefaultProvider), [service]); const provider = useMemo(() => (service ? new NostrStreamProvider("", service) : DefaultProvider), [service]);
const defaultEndpoint = useMemo(() => { const defaultEndpoint = useMemo(() => {
@ -39,14 +45,16 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
}, [info]); }, [info]);
useEffect(() => { useEffect(() => {
provider.info().then(setInfo); if (!isMyManual) {
const t = setInterval(() => {
provider.info().then(setInfo); provider.info().then(setInfo);
}, 1000 * 60); const t = setInterval(() => {
return () => { provider.info().then(setInfo);
clearInterval(t); }, 1000 * 60);
}; return () => {
}, [provider]); clearInterval(t);
};
}
}, [isMyManual, provider]);
const [maxParticipants, setMaxParticipants] = useState(0); const [maxParticipants, setMaxParticipants] = useState(0);
useEffect(() => { useEffect(() => {
@ -69,7 +77,11 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
if (!streamLink && !location.search.includes("setupComplete=true")) return <DashboardIntro />; if (!streamLink && !location.search.includes("setupComplete=true")) return <DashboardIntro />;
return ( return (
<div className="grid grid-cols-3 gap-2 h-[calc(100%-48px-1rem)]"> <div
className={classNames("grid gap-2 h-[calc(100%-48px-1rem)]", {
"grid-cols-3": status === StreamState.Live,
"grid-cols-[20%_80%]": status === StreamState.Ended,
})}>
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2"> <div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
<DashboardCard className="flex flex-col gap-4"> <DashboardCard className="flex flex-col gap-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -88,7 +100,7 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
)} )}
</div> </div>
</div> </div>
{streamLink && ( {streamLink && status === StreamState.Live && !isMyManual && (
<> <>
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" /> <LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
<div className="flex gap-4"> <div className="flex gap-4">
@ -129,14 +141,42 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} /> <NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
<DashboardSettingsButton ev={streamEvent} /> <DashboardSettingsButton ev={streamEvent} />
</div> </div>
{streamEvent?.pubkey === login?.pubkey && (
<DefaultButton>
<FormattedMessage defaultMessage="End Stream" />
</DefaultButton>
)}
</> </>
)} )}
{!streamLink && ( {streamLink && isMyManual && status === StreamState.Live && (
<>
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
<div className="grid gap-2 grid-cols-3">
<DashboardRaidButton link={streamLink} />
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
<WarningButton
onClick={async () => {
//todo: clean this up
const copy = streamEvent ? ({ ...streamEvent } as NostrEvent) : undefined;
const statusTag = copy?.tags.find(a => a[0] === "status");
const endedTag = copy?.tags.find(a => a[0] === "ends");
const pub = login?.signer();
if (statusTag && copy && pub) {
statusTag[1] = StreamState.Ended;
if (endedTag) {
endedTag[1] = String(unixNow());
} else {
copy.tags.push(["ends", String(unixNow())]);
}
copy.created_at = unixNow();
copy.id = EventExt.createId(copy);
const evPub = await pub.sign(copy);
if (evPub) {
await system.BroadcastEvent(evPub);
}
}
}}>
<FormattedMessage defaultMessage="End Stream" />
</WarningButton>
</div>
</>
)}
{(!streamLink || status === StreamState.Ended) && (
<> <>
<div className="bg-layer-1 rounded-xl aspect-video flex items-center justify-center uppercase text-warning font-semibold"> <div className="bg-layer-1 rounded-xl aspect-video flex items-center justify-center uppercase text-warning font-semibold">
<FormattedMessage defaultMessage="Offline" /> <FormattedMessage defaultMessage="Offline" />
@ -155,11 +195,12 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
/> />
</p> </p>
{defaultEndpoint && <StreamKey ep={defaultEndpoint} />} {defaultEndpoint && <StreamKey ep={defaultEndpoint} />}
<ManualStream />
</div> </div>
</> </>
)} )}
</DashboardCard> </DashboardCard>
{streamLink && ( {streamLink && status === StreamState.Live && (
<DashboardCard className="flex flex-col gap-4"> <DashboardCard className="flex flex-col gap-4">
<h3> <h3>
<FormattedMessage defaultMessage="Chat Users" /> <FormattedMessage defaultMessage="Chat Users" />
@ -170,16 +211,22 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
</DashboardCard> </DashboardCard>
)} )}
</div> </div>
{streamLink && ( {streamLink && status === StreamState.Live && (
<> <>
<DashboardZapColumn ev={streamEvent!} link={streamLink} feed={feed} /> <DashboardZapColumn ev={streamEvent!} link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" /> <LiveChat link={streamLink} ev={streamEvent} className="min-h-0 border border-layer-2 rounded-xl px-4 py-3" />
</> </>
)} )}
{!streamLink && ( {streamLink && status === StreamState.Ended && (
<> <>
<DashboardCard></DashboardCard> <DashboardCard className="overflow-y-auto">
<DashboardCard></DashboardCard> <h1>
<FormattedMessage defaultMessage="Last Stream Summary" />
</h1>
<Suspense>
<StreamSummary link={streamLink} />
</Suspense>
</DashboardCard>
</> </>
)} )}
</div> </div>

View File

@ -58,7 +58,7 @@ export default function DashboardIntroStep4() {
<DefaultButton <DefaultButton
onClick={async () => { onClick={async () => {
const pub = login?.publisher(); const pub = login?.publisher();
if (!goal && pub) { if (!goal && pub && goalName && goalAmount) {
const goalEvent = await pub.generic(eb => { const goalEvent = await pub.generic(eb => {
return eb return eb
.kind(GOAL) .kind(GOAL)

View File

@ -0,0 +1,39 @@
import Modal from "@/element/modal";
import { StreamEditor } from "@/element/stream-editor";
import { ManualProvider } from "@/providers/manual";
import { NostrLink } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
export default function ManualStream() {
const [open, setOpen] = useState(false);
const provider = new ManualProvider();
const system = useContext(SnortContext);
const navigate = useNavigate();
return (
<>
<div className="text-primary cursor-pointer select-none" onClick={() => setOpen(true)}>
<FormattedMessage defaultMessage="I have my own stream host" />
</div>
{open && (
<Modal id="new-stream" onClose={() => setOpen(false)}>
<div className="flex flex-col gap-3">
<StreamEditor
onFinish={ex => {
provider.updateStreamInfo(system, ex);
if (!ex) {
navigate(`/${NostrLink.fromEvent(ex).encode()}`, {
state: ex,
});
}
}}
/>
</div>
</Modal>
)}
</>
);
}