forked from Kieran/zap.stream
Merge branch 'main' into message-zaps
This commit is contained in:
commit
de989ec9af
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
.new-stream {
|
.new-stream {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -9,11 +10,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream div.paper {
|
|
||||||
background: #262626;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-stream p {
|
.new-stream p {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
@ -23,14 +19,15 @@
|
|||||||
margin: 8px 0 0 0;
|
margin: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .btn {
|
.new-stream .btn.wide {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .btn>span {
|
.new-stream div.paper {
|
||||||
justify-content: center;
|
background: #262626;
|
||||||
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .btn:disabled {
|
.new-stream .btn:disabled {
|
||||||
@ -48,4 +45,4 @@
|
|||||||
.new-stream .pill.active {
|
.new-stream .pill.active {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: #353535;
|
background: #353535;
|
||||||
}
|
}
|
@ -1,196 +1,75 @@
|
|||||||
import "./new-stream.css";
|
import "./new-stream.css";
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
|
||||||
import { EventPublisher, NostrEvent } from "@snort/system";
|
|
||||||
import { unixNow } from "@snort/shared";
|
|
||||||
|
|
||||||
import AsyncButton from "./async-button";
|
|
||||||
import { StreamState, System } from "index";
|
|
||||||
import { Icon } from "element/icon";
|
import { Icon } from "element/icon";
|
||||||
import { findTag } from "utils";
|
import { useStreamProvider } from "hooks/stream-provider";
|
||||||
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { eventLink } from "utils";
|
||||||
|
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
||||||
|
|
||||||
export function NewStream({
|
function NewStream({ ev, onFinish }: StreamEditorProps) {
|
||||||
ev,
|
const providers = useStreamProvider();
|
||||||
onFinish,
|
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
|
||||||
}: {
|
const navigate = useNavigate();
|
||||||
ev?: NostrEvent;
|
|
||||||
onFinish?: (ev: NostrEvent) => void;
|
|
||||||
}) {
|
|
||||||
const [title, setTitle] = useState(findTag(ev, "title") ?? "");
|
|
||||||
const [summary, setSummary] = useState(findTag(ev, "summary") ?? "");
|
|
||||||
const [image, setImage] = useState(findTag(ev, "image") ?? "");
|
|
||||||
const [stream, setStream] = useState(findTag(ev, "streaming") ?? "");
|
|
||||||
const [status, setStatus] = useState(
|
|
||||||
findTag(ev, "status") ?? StreamState.Live
|
|
||||||
);
|
|
||||||
const [start, setStart] = useState(findTag(ev, "starts"));
|
|
||||||
const [isValid, setIsValid] = useState(false);
|
|
||||||
|
|
||||||
const validate = useCallback(() => {
|
|
||||||
if (title.length < 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (stream.length < 5 || !stream.match(/^https?:\/\/.*\.m3u8?$/i)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (image.length > 0 && !image.match(/^https?:\/\//i)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, [title, image, stream]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsValid(validate());
|
if (!currentProvider) {
|
||||||
}, [validate, title, summary, image, stream]);
|
setCurrentProvider(providers.at(0));
|
||||||
|
}
|
||||||
|
}, [providers, currentProvider]);
|
||||||
|
|
||||||
async function publishStream() {
|
|
||||||
const pub = await EventPublisher.nip7();
|
|
||||||
if (pub) {
|
function providerDialog() {
|
||||||
const evNew = await pub.generic((eb) => {
|
if (!currentProvider) return;
|
||||||
const now = unixNow();
|
|
||||||
const dTag = findTag(ev, "d") ?? now.toString();
|
switch (currentProvider.type) {
|
||||||
const starts = start ?? now.toString();
|
case StreamProviders.Manual: {
|
||||||
const ends = findTag(ev, "ends") ?? now.toString();
|
return <StreamEditor onFinish={ex => {
|
||||||
eb.kind(30_311)
|
currentProvider.updateStreamInfo(ex);
|
||||||
.tag(["d", dTag])
|
if (!ev) {
|
||||||
.tag(["title", title])
|
navigate(eventLink(ex));
|
||||||
.tag(["summary", summary])
|
} else {
|
||||||
.tag(["image", image])
|
onFinish?.(ev);
|
||||||
.tag(["streaming", stream])
|
}
|
||||||
.tag(["status", status])
|
}} ev={ev} />
|
||||||
.tag(["starts", starts]);
|
}
|
||||||
if (status === StreamState.Ended) {
|
case StreamProviders.NostrType: {
|
||||||
eb.tag(["ends", ends]);
|
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />
|
||||||
}
|
}
|
||||||
return eb;
|
case StreamProviders.Owncast: {
|
||||||
});
|
return
|
||||||
console.debug(evNew);
|
}
|
||||||
System.BroadcastEvent(evNew);
|
|
||||||
onFinish && onFinish(evNew);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTimeString(n: number) {
|
return <>
|
||||||
return new Date(n * 1000).toISOString().substring(0, -1);
|
<p>Stream Providers</p>
|
||||||
}
|
<div className="flex g12">
|
||||||
|
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)}
|
||||||
function fromDateTimeString(s: string) {
|
|
||||||
return Math.floor(new Date(s).getTime() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="new-stream">
|
|
||||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
|
||||||
<div>
|
|
||||||
<p>Title</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="What are we steaming today?"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>Summary</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="A short description of the content"
|
|
||||||
value={summary}
|
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>Cover image</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://"
|
|
||||||
value={image}
|
|
||||||
onChange={(e) => setImage(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>Stream Url</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://"
|
|
||||||
value={stream}
|
|
||||||
onChange={(e) => setStream(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<small>Stream type should be HLS</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>Status</p>
|
|
||||||
<div className="flex g12">
|
|
||||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
|
||||||
(v) => (
|
|
||||||
<span
|
|
||||||
className={`pill${status === v ? " active" : ""}`}
|
|
||||||
onClick={() => setStatus(v)}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status === StreamState.Planned && (
|
|
||||||
<div>
|
|
||||||
<p>Start Time</p>
|
|
||||||
<div className="input">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={toDateTimeString(Number(start ?? "0"))}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStart(fromDateTimeString(e.target.value).toString())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<AsyncButton
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={!isValid}
|
|
||||||
onClick={publishStream}
|
|
||||||
>
|
|
||||||
{ev ? "Save" : "Start Stream"}
|
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{providerDialog()}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewStreamDialogProps {
|
interface NewStreamDialogProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
btnClassName?: string;
|
btnClassName?: string;
|
||||||
ev?: NostrEvent;
|
|
||||||
onFinish?: (e: NostrEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewStreamDialog({
|
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
|
||||||
text,
|
const [open, setOpen] = useState(false);
|
||||||
ev,
|
|
||||||
onFinish,
|
|
||||||
btnClassName = "btn",
|
|
||||||
}: NewStreamDialogProps) {
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root>
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
<Dialog.Trigger asChild>
|
<Dialog.Trigger asChild>
|
||||||
<button type="button" className={btnClassName}>
|
<button type="button" className={props.btnClassName}>
|
||||||
{text && text}
|
{props.text && props.text}
|
||||||
{!text && (
|
{!props.text && (
|
||||||
<>
|
<>
|
||||||
<span className="hide-on-mobile">New Stream</span>
|
<span className="hide-on-mobile">Stream</span>
|
||||||
<Icon name="signal" />
|
<Icon name="signal" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -199,7 +78,9 @@ export function NewStreamDialog({
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-overlay" />
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
<Dialog.Content className="dialog-content">
|
<Dialog.Content className="dialog-content">
|
||||||
<NewStream ev={ev} onFinish={onFinish} />
|
<div className="new-stream">
|
||||||
|
<NewStream {...props} onFinish={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
76
src/element/nostr-provider-dialog.tsx
Normal file
76
src/element/nostr-provider-dialog.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { StreamProvider, StreamProviderInfo } from "providers";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { SendZaps } from "./send-zap";
|
||||||
|
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||||
|
import Spinner from "./spinner";
|
||||||
|
|
||||||
|
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
|
||||||
|
const [topup, setTopup] = useState(false);
|
||||||
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider && !info) {
|
||||||
|
provider.info().then(v => setInfo(v));
|
||||||
|
}
|
||||||
|
}, [info, provider])
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return <Spinner />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topup) {
|
||||||
|
return <SendZaps lnurl={{
|
||||||
|
name: provider.name,
|
||||||
|
canZap: false,
|
||||||
|
maxCommentLength: 0,
|
||||||
|
getInvoice: async (amount) => {
|
||||||
|
const pr = await provider.topup(amount);
|
||||||
|
return { pr };
|
||||||
|
}
|
||||||
|
}} onFinish={() => {
|
||||||
|
provider.info().then(v => {
|
||||||
|
setInfo(v);
|
||||||
|
setTopup(false);
|
||||||
|
});
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamEvent = others.ev ?? info.publishedEvent;
|
||||||
|
return <>
|
||||||
|
<div>
|
||||||
|
<p>Stream Url</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input type="text" value={info.ingressUrl} disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>Stream Key</p>
|
||||||
|
<div className="flex g12">
|
||||||
|
<div className="paper f-grow">
|
||||||
|
<input type="password" value={info.ingressKey} disabled />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(info.ingressKey ?? "")}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>Balance</p>
|
||||||
|
<div className="flex g12">
|
||||||
|
<div className="paper f-grow">
|
||||||
|
{info.balance?.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||||
|
Topup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{streamEvent && <StreamEditor onFinish={(ex) => {
|
||||||
|
provider.updateStreamInfo(ex);
|
||||||
|
others.onFinish?.(ex);
|
||||||
|
}} ev={streamEvent} options={{
|
||||||
|
canSetStream: false,
|
||||||
|
canSetStatus: false
|
||||||
|
}} />}
|
||||||
|
</>
|
||||||
|
}
|
@ -9,8 +9,15 @@ import AsyncButton from "./async-button";
|
|||||||
import { Relays } from "index";
|
import { Relays } from "index";
|
||||||
import QrCode from "./qr-code";
|
import QrCode from "./qr-code";
|
||||||
|
|
||||||
interface SendZapsProps {
|
export interface LNURLLike {
|
||||||
lnurl: string;
|
get name(): string;
|
||||||
|
get maxCommentLength(): number;
|
||||||
|
get canZap(): boolean;
|
||||||
|
getInvoice(amountInSats: number, comment?: string, zap?: NostrEvent): Promise<{ pr?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendZapsProps {
|
||||||
|
lnurl: string | LNURLLike;
|
||||||
pubkey?: string;
|
pubkey?: string;
|
||||||
aTag?: string;
|
aTag?: string;
|
||||||
eTag?: string;
|
eTag?: string;
|
||||||
@ -19,7 +26,7 @@ interface SendZapsProps {
|
|||||||
button?: ReactNode;
|
button?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendZaps({
|
export function SendZaps({
|
||||||
lnurl,
|
lnurl,
|
||||||
pubkey,
|
pubkey,
|
||||||
aTag,
|
aTag,
|
||||||
@ -34,13 +41,13 @@ function SendZaps({
|
|||||||
];
|
];
|
||||||
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
|
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
|
||||||
const [isFiat, setIsFiat] = useState(false);
|
const [isFiat, setIsFiat] = useState(false);
|
||||||
const [svc, setSvc] = useState<LNURL>();
|
const [svc, setSvc] = useState<LNURLLike>();
|
||||||
const [amount, setAmount] = useState(satsAmounts[0]);
|
const [amount, setAmount] = useState(satsAmounts[0]);
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [invoice, setInvoice] = useState("");
|
const [invoice, setInvoice] = useState("");
|
||||||
|
|
||||||
const name = targetName ?? svc?.name;
|
const name = targetName ?? svc?.name;
|
||||||
async function loadService() {
|
async function loadService(lnurl: string) {
|
||||||
const s = new LNURL(lnurl);
|
const s = new LNURL(lnurl);
|
||||||
await s.load();
|
await s.load();
|
||||||
setSvc(s);
|
setSvc(s);
|
||||||
@ -48,7 +55,11 @@ function SendZaps({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!svc) {
|
if (!svc) {
|
||||||
loadService().catch(console.warn);
|
if (typeof lnurl === "string") {
|
||||||
|
loadService(lnurl).catch(console.warn);
|
||||||
|
} else {
|
||||||
|
setSvc(lnurl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [lnurl]);
|
}, [lnurl]);
|
||||||
|
|
||||||
@ -127,7 +138,7 @@ function SendZaps({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{svc && (svc.maxCommentLength > 0 || svc.canZap) && <div>
|
||||||
<small>Your comment for {name}</small>
|
<small>Your comment for {name}</small>
|
||||||
<div className="paper">
|
<div className="paper">
|
||||||
<textarea
|
<textarea
|
||||||
@ -136,7 +147,7 @@ function SendZaps({
|
|||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
<div>
|
<div>
|
||||||
<AsyncButton onClick={send} className="btn btn-primary">
|
<AsyncButton onClick={send} className="btn btn-primary">
|
||||||
Zap!
|
Zap!
|
||||||
@ -150,7 +161,12 @@ function SendZaps({
|
|||||||
if (!invoice) return;
|
if (!invoice) return;
|
||||||
|
|
||||||
const link = `lightning:${invoice}`;
|
const link = `lightning:${invoice}`;
|
||||||
return <QrCode data={link} link={link} />;
|
return <>
|
||||||
|
<QrCode data={link} link={link} />
|
||||||
|
<button className="btn btn-primary wide" onClick={() => onFinish()}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
166
src/element/stream-editor.tsx
Normal file
166
src/element/stream-editor.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { EventPublisher, NostrEvent } from "@snort/system";
|
||||||
|
import { unixNow } from "@snort/shared";
|
||||||
|
|
||||||
|
import AsyncButton from "./async-button";
|
||||||
|
import { StreamState, System } from "index";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
|
||||||
|
export interface StreamEditorProps {
|
||||||
|
ev?: NostrEvent;
|
||||||
|
onFinish?: (ev: NostrEvent) => void;
|
||||||
|
options?: {
|
||||||
|
canSetTitle?: boolean
|
||||||
|
canSetSummary?: boolean
|
||||||
|
canSetImage?: boolean
|
||||||
|
canSetStatus?: boolean
|
||||||
|
canSetStream?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||||
|
const [title, setTitle] = useState(findTag(ev, "title") ?? "");
|
||||||
|
const [summary, setSummary] = useState(findTag(ev, "summary") ?? "");
|
||||||
|
const [image, setImage] = useState(findTag(ev, "image") ?? "");
|
||||||
|
const [stream, setStream] = useState(findTag(ev, "streaming") ?? "");
|
||||||
|
const [status, setStatus] = useState(
|
||||||
|
findTag(ev, "status") ?? StreamState.Live
|
||||||
|
);
|
||||||
|
const [start, setStart] = useState(findTag(ev, "starts"));
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
const validate = useCallback(() => {
|
||||||
|
if (title.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stream.length < 5 || !stream.match(/^https?:\/\/.*\.m3u8?$/i)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (image.length > 0 && !image.match(/^https?:\/\//i)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [title, image, stream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsValid(ev !== undefined || validate());
|
||||||
|
}, [validate, title, summary, image, stream]);
|
||||||
|
|
||||||
|
async function publishStream() {
|
||||||
|
const pub = await EventPublisher.nip7();
|
||||||
|
if (pub) {
|
||||||
|
const evNew = await pub.generic((eb) => {
|
||||||
|
const now = unixNow();
|
||||||
|
const dTag = findTag(ev, "d") ?? now.toString();
|
||||||
|
const starts = start ?? now.toString();
|
||||||
|
const ends = findTag(ev, "ends") ?? now.toString();
|
||||||
|
eb.kind(30311)
|
||||||
|
.tag(["d", dTag])
|
||||||
|
.tag(["title", title])
|
||||||
|
.tag(["summary", summary])
|
||||||
|
.tag(["image", image])
|
||||||
|
.tag(["streaming", stream])
|
||||||
|
.tag(["status", status])
|
||||||
|
.tag(["starts", starts]);
|
||||||
|
if (status === StreamState.Ended) {
|
||||||
|
eb.tag(["ends", ends]);
|
||||||
|
}
|
||||||
|
return eb;
|
||||||
|
});
|
||||||
|
console.debug(evNew);
|
||||||
|
onFinish && onFinish(evNew);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeString(n: number) {
|
||||||
|
return new Date(n * 1000).toISOString().substring(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDateTimeString(s: string) {
|
||||||
|
return Math.floor(new Date(s).getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||||
|
{(options?.canSetTitle === undefined || options.canSetTitle) && <div>
|
||||||
|
<p>Title</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="What are we steaming today?"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{(options?.canSetSummary === undefined || options.canSetSummary) && <div>
|
||||||
|
<p>Summary</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="A short description of the content"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{(options?.canSetImage === undefined || options.canSetImage) && <div>
|
||||||
|
<p>Cover image</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://"
|
||||||
|
value={image}
|
||||||
|
onChange={(e) => setImage(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{(options?.canSetStream === undefined || options.canSetStream) && <div>
|
||||||
|
<p>Stream Url</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://"
|
||||||
|
value={stream}
|
||||||
|
onChange={(e) => setStream(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<small>Stream type should be HLS</small>
|
||||||
|
</div>}
|
||||||
|
{(options?.canSetStatus === undefined || options.canSetStatus) && <><div>
|
||||||
|
<p>Status</p>
|
||||||
|
<div className="flex g12">
|
||||||
|
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
||||||
|
(v) => (
|
||||||
|
<span
|
||||||
|
className={`pill${status === v ? " active" : ""}`}
|
||||||
|
onClick={() => setStatus(v)}
|
||||||
|
key={v}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status === StreamState.Planned && (
|
||||||
|
<div>
|
||||||
|
<p>Start Time</p>
|
||||||
|
<div className="input">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={toDateTimeString(Number(start ?? "0"))}
|
||||||
|
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}</>}
|
||||||
|
<div>
|
||||||
|
<AsyncButton
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary wide"
|
||||||
|
disabled={!isValid}
|
||||||
|
onClick={publishStream}
|
||||||
|
>
|
||||||
|
{ev ? "Save" : "Start Stream"}
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -31,7 +31,7 @@ export function VideoTile({
|
|||||||
ev.pubkey
|
ev.pubkey
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Link to={`/live/${link}`} className="video-tile" ref={ref}>
|
<Link to={`/${link}`} className="video-tile" ref={ref}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${inView ? image : ""})`,
|
backgroundImage: `url(${inView ? image : ""})`,
|
||||||
|
6
src/hooks/stream-provider.tsx
Normal file
6
src/hooks/stream-provider.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { StreamProviderStore } from "providers";
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export function useStreamProvider() {
|
||||||
|
return useSyncExternalStore(c => StreamProviderStore.hook(c), () => StreamProviderStore.snapshot());
|
||||||
|
}
|
@ -51,6 +51,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/live/:id",
|
path: "/live/:id",
|
||||||
element: <StreamPage />,
|
element: <StreamPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/:id",
|
||||||
|
element: <StreamPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/providers/:id?",
|
path: "/providers/:id?",
|
||||||
element: <StreamProvidersPage />,
|
element: <StreamProvidersPage />,
|
||||||
|
@ -2,9 +2,6 @@ import { Icon } from "element/icon";
|
|||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import {
|
import {
|
||||||
EventPublisher,
|
EventPublisher,
|
||||||
NostrEvent,
|
|
||||||
encodeTLV,
|
|
||||||
NostrPrefix,
|
|
||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||||
import AsyncButton from "element/async-button";
|
import AsyncButton from "element/async-button";
|
||||||
@ -30,7 +27,7 @@ export function LayoutPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NewStreamDialog btnClassName="btn btn-primary" onFinish={goToStream} />
|
<NewStreamDialog btnClassName="btn btn-primary" />
|
||||||
<Profile
|
<Profile
|
||||||
avatarClassname="mb-squared"
|
avatarClassname="mb-squared"
|
||||||
pubkey={login.pubkey}
|
pubkey={login.pubkey}
|
||||||
@ -55,22 +52,10 @@ export function LayoutPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToStream(ev: NostrEvent) {
|
|
||||||
const d = ev.tags.find((t) => t.at(0) === "d")?.at(1) || "";
|
|
||||||
const naddr = encodeTLV(
|
|
||||||
NostrPrefix.Address,
|
|
||||||
d,
|
|
||||||
undefined,
|
|
||||||
ev.kind,
|
|
||||||
ev.pubkey
|
|
||||||
);
|
|
||||||
navigate(`/${naddr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
location.pathname === "/" || location.pathname.startsWith("/p/")
|
location.pathname === "/" || location.pathname.startsWith("/p/") || location.pathname.startsWith("/providers")
|
||||||
? "page only-content"
|
? "page only-content"
|
||||||
: location.pathname.startsWith("/chat/")
|
: location.pathname.startsWith("/chat/")
|
||||||
? "page chat"
|
? "page chat"
|
||||||
|
@ -77,7 +77,7 @@ export function ProfilePage() {
|
|||||||
liveEvent.kind,
|
liveEvent.kind,
|
||||||
liveEvent.pubkey
|
liveEvent.pubkey
|
||||||
);
|
);
|
||||||
navigate(`/live/${naddr}`);
|
navigate(`/${naddr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,3 @@
|
|||||||
.stream-providers-page {
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-providers-grid {
|
.stream-providers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
@ -5,6 +5,7 @@ import Owncast from "owncast.png";
|
|||||||
import Cloudflare from "cloudflare.png";
|
import Cloudflare from "cloudflare.png";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ConfigureOwncast } from "./owncast";
|
import { ConfigureOwncast } from "./owncast";
|
||||||
|
import { ConfigureNostrType } from "./nostr";
|
||||||
|
|
||||||
export function StreamProvidersPage() {
|
export function StreamProvidersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -14,6 +15,7 @@ export function StreamProvidersPage() {
|
|||||||
switch (p) {
|
switch (p) {
|
||||||
case StreamProviders.Owncast: return "Owncast"
|
case StreamProviders.Owncast: return "Owncast"
|
||||||
case StreamProviders.Cloudflare: return "Cloudflare"
|
case StreamProviders.Cloudflare: return "Cloudflare"
|
||||||
|
case StreamProviders.NostrType: return "Nostr Native"
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@ -40,7 +42,7 @@ export function StreamProvidersPage() {
|
|||||||
<h1>Providers</h1>
|
<h1>Providers</h1>
|
||||||
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
|
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
|
||||||
<div className="stream-providers-grid">
|
<div className="stream-providers-grid">
|
||||||
{[StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
|
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div >
|
||||||
}
|
}
|
||||||
@ -52,6 +54,9 @@ export function StreamProvidersPage() {
|
|||||||
case StreamProviders.Owncast: {
|
case StreamProviders.Owncast: {
|
||||||
return <ConfigureOwncast />
|
return <ConfigureOwncast />
|
||||||
}
|
}
|
||||||
|
case StreamProviders.NostrType: {
|
||||||
|
return <ConfigureNostrType />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
83
src/pages/providers/nostr.tsx
Normal file
83
src/pages/providers/nostr.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import AsyncButton from "element/async-button";
|
||||||
|
import { StatePill } from "element/state-pill";
|
||||||
|
import { StreamState } from "index";
|
||||||
|
import { StreamProviderInfo, StreamProviderStore } from "providers";
|
||||||
|
import { Nip103StreamProvider } from "providers/nip103";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ConfigureNostrType() {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function tryConnect() {
|
||||||
|
try {
|
||||||
|
const api = new Nip103StreamProvider(url);
|
||||||
|
const inf = await api.info();
|
||||||
|
setInfo(inf);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<h3>Status</h3>
|
||||||
|
<div>
|
||||||
|
<StatePill state={info?.state ?? StreamState.Ended} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>Name</p>
|
||||||
|
<div className="paper">
|
||||||
|
{info?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{info?.summary && <div>
|
||||||
|
<p>Summary</p>
|
||||||
|
<div className="paper">
|
||||||
|
{info?.summary}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{info?.viewers && <div>
|
||||||
|
<p>Viewers</p>
|
||||||
|
<div className="paper">
|
||||||
|
{info?.viewers}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
{info?.version && <div>
|
||||||
|
<p>Version</p>
|
||||||
|
<div className="paper">
|
||||||
|
{info?.version}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
<div>
|
||||||
|
<button className="btn btn-border" onClick={() => {
|
||||||
|
StreamProviderStore.add(new Nip103StreamProvider(url));
|
||||||
|
navigate("/");
|
||||||
|
}}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="owncast-config">
|
||||||
|
<div className="flex f-col g24">
|
||||||
|
<div>
|
||||||
|
<p>Nostr streaming provider URL</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||||
|
Connect
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{status()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
@ -1,14 +1,16 @@
|
|||||||
import AsyncButton from "element/async-button";
|
import AsyncButton from "element/async-button";
|
||||||
import { StatePill } from "element/state-pill";
|
import { StatePill } from "element/state-pill";
|
||||||
import { StreamState } from "index";
|
import { StreamState } from "index";
|
||||||
import { StreamProviderInfo } from "providers";
|
import { StreamProviderInfo, StreamProviderStore } from "providers";
|
||||||
import { OwncastProvider } from "providers/owncast";
|
import { OwncastProvider } from "providers/owncast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export function ConfigureOwncast() {
|
export function ConfigureOwncast() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function tryConnect() {
|
async function tryConnect() {
|
||||||
try {
|
try {
|
||||||
@ -54,7 +56,10 @@ export function ConfigureOwncast() {
|
|||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<div>
|
<div>
|
||||||
<button className="btn btn-border">
|
<button className="btn btn-border" onClick={() => {
|
||||||
|
StreamProviderStore.add(new OwncastProvider(url, token));
|
||||||
|
navigate("/");
|
||||||
|
}}>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,15 @@
|
|||||||
import { StreamState } from "index"
|
import { StreamState } from "index"
|
||||||
|
import { NostrEvent } from "@snort/system";
|
||||||
|
import { ExternalStore } from "@snort/shared";
|
||||||
|
import { Nip103StreamProvider } from "./nip103";
|
||||||
|
import { ManualProvider } from "./manual";
|
||||||
|
import { OwncastProvider } from "./owncast";
|
||||||
|
|
||||||
|
|
||||||
export interface StreamProvider {
|
export interface StreamProvider {
|
||||||
|
get name(): string
|
||||||
|
get type(): StreamProviders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get general info about connected provider to test everything is working
|
* Get general info about connected provider to test everything is working
|
||||||
*/
|
*/
|
||||||
@ -10,11 +19,23 @@ export interface StreamProvider {
|
|||||||
* Create a config object to save in localStorage
|
* Create a config object to save in localStorage
|
||||||
*/
|
*/
|
||||||
createConfig(): any & { type: StreamProviders }
|
createConfig(): any & { type: StreamProviders }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update stream info event
|
||||||
|
*/
|
||||||
|
updateStreamInfo(ev: NostrEvent): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-up balance with provider
|
||||||
|
*/
|
||||||
|
topup(amount: number): Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StreamProviders {
|
export enum StreamProviders {
|
||||||
|
Manual = "manual",
|
||||||
Owncast = "owncast",
|
Owncast = "owncast",
|
||||||
Cloudflare = "cloudflare"
|
Cloudflare = "cloudflare",
|
||||||
|
NostrType = "nostr"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamProviderInfo {
|
export interface StreamProviderInfo {
|
||||||
@ -22,5 +43,54 @@ export interface StreamProviderInfo {
|
|||||||
summary?: string
|
summary?: string
|
||||||
version?: string
|
version?: string
|
||||||
state: StreamState
|
state: StreamState
|
||||||
viewers: number
|
viewers?: number
|
||||||
|
ingressUrl?: string
|
||||||
|
ingressKey?: string
|
||||||
|
balance?: number
|
||||||
|
publishedEvent?: NostrEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||||
|
#providers: Array<StreamProvider> = []
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const cache = window.localStorage.getItem("providers");
|
||||||
|
if (cache) {
|
||||||
|
const cached: Array<{ type: StreamProviders } & any> = JSON.parse(cache);
|
||||||
|
for (const c of cached) {
|
||||||
|
switch (c.type) {
|
||||||
|
case StreamProviders.Manual: {
|
||||||
|
this.#providers.push(new ManualProvider());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StreamProviders.NostrType: {
|
||||||
|
this.#providers.push(new Nip103StreamProvider(c.url));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case StreamProviders.Owncast: {
|
||||||
|
this.#providers.push(new OwncastProvider(c.url, c.token));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(p: StreamProvider) {
|
||||||
|
this.#providers.push(p);
|
||||||
|
this.#save();
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeSnapshot() {
|
||||||
|
return [new Nip103StreamProvider("https://api.zap.stream/api/nostr/"), new ManualProvider(), ...this.#providers];
|
||||||
|
}
|
||||||
|
|
||||||
|
#save() {
|
||||||
|
const cfg = this.#providers.map(a => a.createConfig());
|
||||||
|
window.localStorage.setItem("providers", JSON.stringify(cfg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamProviderStore = new ProviderStore();
|
34
src/providers/manual.ts
Normal file
34
src/providers/manual.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NostrEvent } from "@snort/system";
|
||||||
|
import { System } from "index";
|
||||||
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
||||||
|
|
||||||
|
export class ManualProvider implements StreamProvider {
|
||||||
|
get name(): string {
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return StreamProviders.Manual
|
||||||
|
}
|
||||||
|
|
||||||
|
info(): Promise<StreamProviderInfo> {
|
||||||
|
return Promise.resolve({
|
||||||
|
name: this.name
|
||||||
|
} as StreamProviderInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
createConfig() {
|
||||||
|
return {
|
||||||
|
type: StreamProviders.Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStreamInfo(ev: NostrEvent): Promise<void> {
|
||||||
|
System.BroadcastEvent(ev);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
topup(amount: number): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
97
src/providers/nip103.ts
Normal file
97
src/providers/nip103.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from ".";
|
||||||
|
import { EventPublisher, EventKind, NostrEvent } from "@snort/system";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
|
||||||
|
export class Nip103StreamProvider implements StreamProvider {
|
||||||
|
#url: string
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.#url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return new URL(this.#url).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return StreamProviders.NostrType
|
||||||
|
}
|
||||||
|
|
||||||
|
async info() {
|
||||||
|
const rsp = await this.#getJson<AccountResponse>("GET", "account");
|
||||||
|
const title = findTag(rsp.event, "title");
|
||||||
|
const state = findTag(rsp.event, "status");
|
||||||
|
return {
|
||||||
|
type: StreamProviders.NostrType,
|
||||||
|
name: title ?? "",
|
||||||
|
state: state,
|
||||||
|
viewers: 0,
|
||||||
|
ingressUrl: rsp.url,
|
||||||
|
ingressKey: rsp.key,
|
||||||
|
balance: rsp.quota.remaining,
|
||||||
|
publishedEvent: rsp.event
|
||||||
|
} as StreamProviderInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
createConfig() {
|
||||||
|
return {
|
||||||
|
type: StreamProviders.NostrType,
|
||||||
|
url: this.#url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStreamInfo(ev: NostrEvent): Promise<void> {
|
||||||
|
const title = findTag(ev, "title");
|
||||||
|
const summary = findTag(ev, "summary");
|
||||||
|
const image = findTag(ev, "image");
|
||||||
|
await this.#getJson("PATCH", "event", {
|
||||||
|
title, summary, image
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async topup(amount: number): Promise<string> {
|
||||||
|
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
|
||||||
|
return rsp.pr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
|
||||||
|
const pub = await EventPublisher.nip7();
|
||||||
|
if (!pub) throw new Error("No event publisher");
|
||||||
|
|
||||||
|
const u = `${this.#url}${path}`;
|
||||||
|
const token = await pub.generic(eb => {
|
||||||
|
return eb.kind(EventKind.HttpAuthentication)
|
||||||
|
.content("")
|
||||||
|
.tag(["u", u])
|
||||||
|
.tag(["method", method])
|
||||||
|
});
|
||||||
|
const rsp = await fetch(u, {
|
||||||
|
method: method,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"authorization": `Nostr ${btoa(JSON.stringify(token))}`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await rsp.text();
|
||||||
|
if (!rsp.ok) {
|
||||||
|
throw new Error(json);
|
||||||
|
}
|
||||||
|
return json.length > 0 ? JSON.parse(json) as T : {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountResponse {
|
||||||
|
url: string
|
||||||
|
key: string
|
||||||
|
event?: NostrEvent
|
||||||
|
quota: {
|
||||||
|
unit: string
|
||||||
|
rate: number
|
||||||
|
remaining: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopUpResponse {
|
||||||
|
pr: string
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { NostrEvent } from "@snort/system";
|
||||||
import { StreamState } from "index";
|
import { StreamState } from "index";
|
||||||
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
||||||
|
|
||||||
@ -10,6 +11,14 @@ export class OwncastProvider implements StreamProvider {
|
|||||||
this.#token = token;
|
this.#token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return new URL(this.#url).host
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return StreamProviders.Owncast
|
||||||
|
}
|
||||||
|
|
||||||
createConfig(): any & { type: StreamProviders; } {
|
createConfig(): any & { type: StreamProviders; } {
|
||||||
return {
|
return {
|
||||||
type: StreamProviders.Owncast,
|
type: StreamProviders.Owncast,
|
||||||
@ -18,10 +27,15 @@ export class OwncastProvider implements StreamProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateStreamInfo(ev: NostrEvent): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
async info() {
|
async info() {
|
||||||
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
|
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
|
||||||
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
|
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
|
||||||
return {
|
return {
|
||||||
|
type: StreamProviders.Owncast,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
summary: info.summary,
|
summary: info.summary,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
@ -30,6 +44,10 @@ export class OwncastProvider implements StreamProvider {
|
|||||||
} as StreamProviderInfo
|
} as StreamProviderInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
topup(amount: number): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||||
const rsp = await fetch(`${this.#url}${path}`, {
|
const rsp = await fetch(`${this.#url}${path}`, {
|
||||||
method: method,
|
method: method,
|
||||||
|
12
src/utils.ts
12
src/utils.ts
@ -40,3 +40,15 @@ export function splitByUrl(str: string) {
|
|||||||
|
|
||||||
return str.split(urlRegex);
|
return str.split(urlRegex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function eventLink(ev: NostrEvent) {
|
||||||
|
const d = findTag(ev, "d") ?? "";
|
||||||
|
const naddr = encodeTLV(
|
||||||
|
NostrPrefix.Address,
|
||||||
|
d,
|
||||||
|
undefined,
|
||||||
|
ev.kind,
|
||||||
|
ev.pubkey
|
||||||
|
);
|
||||||
|
return `/${naddr}`;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user