diff --git a/src/element/new-stream.css b/src/element/new-stream.css index e3ad1a1..b46fd51 100644 --- a/src/element/new-stream.css +++ b/src/element/new-stream.css @@ -1,3 +1,4 @@ + .new-stream { display: flex; flex-direction: column; @@ -9,11 +10,6 @@ margin: 0; } -.new-stream div.paper { - background: #262626; - height: 32px; -} - .new-stream p { margin: 0 0 8px 0; } @@ -23,14 +19,15 @@ margin: 8px 0 0 0; } -.new-stream .btn { +.new-stream .btn.wide { padding: 12px 16px; border-radius: 16px; width: 100%; } -.new-stream .btn>span { - justify-content: center; +.new-stream div.paper { + background: #262626; + height: 32px; } .new-stream .btn:disabled { @@ -48,4 +45,4 @@ .new-stream .pill.active { color: inherit; background: #353535; -} +} \ No newline at end of file diff --git a/src/element/new-stream.tsx b/src/element/new-stream.tsx index 27da545..8f5eb7b 100644 --- a/src/element/new-stream.tsx +++ b/src/element/new-stream.tsx @@ -1,196 +1,118 @@ import "./new-stream.css"; 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 { 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"; -export function NewStream({ - ev, - onFinish, -}: { - 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); +function NewStream({ ev, onFinish }: StreamEditorProps) { + const providers = useStreamProvider(); + const [currentProvider, setCurrentProvider] = useState(); + const [info, setInfo] = useState(); + const navigate = useNavigate(); - 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]); + async function loadInfo(p: StreamProvider) { + const inf = await p.info(); + setInfo(inf); + } useEffect(() => { - setIsValid(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(30_311) - .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); - System.BroadcastEvent(evNew); - onFinish && onFinish(evNew); + if (!currentProvider) { + setCurrentProvider(providers.at(0)); } - } + if (currentProvider) { + loadInfo(currentProvider).catch(console.error); + } + }, [providers, currentProvider]); - 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 ( -
-

{ev ? "Edit Stream" : "New Stream"}

-
-

Title

-
- setTitle(e.target.value)} - /> -
-
-
-

Summary

-
- setSummary(e.target.value)} - /> -
-
-
-

Cover image

-
- setImage(e.target.value)} - /> -
-
+ function nostrTypeDialog(p: StreamProviderInfo) { + return <>

Stream Url

- setStream(e.target.value)} - /> +
- Stream type should be HLS
-

Status

+

Stream Key

+
+ +
+
+
+

Balance

- {[StreamState.Live, StreamState.Planned, StreamState.Ended].map( - (v) => ( - setStatus(v)} - > - {v} - - ) - )} -
-
- {status === StreamState.Planned && ( -
-

Start Time

-
- - setStart(fromDateTimeString(e.target.value).toString()) - } - /> +
+ {p.balance?.toLocaleString()} sats
+
- )} -
- - {ev ? "Save" : "Start Stream"} - +
+ + } + + function providerDialog(p: StreamProviderInfo) { + switch (p.type) { + case StreamProviders.Manual: { + return { + currentProvider?.updateStreamInfo(ex); + if (!ev) { + navigate(eventLink(ex)); + } else { + onFinish?.(ev); + } + }} ev={ev} /> + } + case StreamProviders.NostrType: { + return <> + {nostrTypeDialog(p)} + { + // patch to api + currentProvider?.updateStreamInfo(ex); + onFinish?.(ex); + }} ev={ev ?? p.publishedEvent} options={{ + canSetStream: false, + canSetStatus: false + }} /> + + } + case StreamProviders.Owncast: { + return + } + } + } + + return <> +

Stream Providers

+
+ {providers.map(v => setCurrentProvider(v)}>{v.name})}
- ); + {info && providerDialog(info)} + } interface NewStreamDialogProps { text?: string; btnClassName?: string; - ev?: NostrEvent; - onFinish?: (e: NostrEvent) => void; } -export function NewStreamDialog({ - text, - ev, - onFinish, - btnClassName = "btn", -}: NewStreamDialogProps) { +export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) { + const [open, setOpen] = useState(false); return ( - + - +
+ + } + + return
+
+
+

Nostr streaming provider URL

+
+ setUrl(e.target.value)} /> +
+
+ + Connect + +
+
+ {status()} +
+
+} \ No newline at end of file diff --git a/src/pages/providers/owncast.tsx b/src/pages/providers/owncast.tsx index b9a7054..d41c725 100644 --- a/src/pages/providers/owncast.tsx +++ b/src/pages/providers/owncast.tsx @@ -1,14 +1,16 @@ import AsyncButton from "element/async-button"; import { StatePill } from "element/state-pill"; import { StreamState } from "index"; -import { StreamProviderInfo } from "providers"; +import { StreamProviderInfo, StreamProviderStore } from "providers"; import { OwncastProvider } from "providers/owncast"; import { useState } from "react"; +import { useNavigate } from "react-router-dom"; export function ConfigureOwncast() { const [url, setUrl] = useState(""); const [token, setToken] = useState(""); const [info, setInfo] = useState(); + const navigate = useNavigate(); async function tryConnect() { try { @@ -54,7 +56,10 @@ export function ConfigureOwncast() {
}
-
diff --git a/src/providers/index.ts b/src/providers/index.ts index 7c8c344..57a3024 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,14 @@ 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 { + get name(): string + /** * Get general info about connected provider to test everything is working */ @@ -10,17 +18,74 @@ export interface StreamProvider { * Create a config object to save in localStorage */ createConfig(): any & { type: StreamProviders } + + /** + * Update stream info event + */ + updateStreamInfo(ev: NostrEvent): Promise; } export enum StreamProviders { + Manual = "manual", Owncast = "owncast", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + NostrType = "nostr" } export interface StreamProviderInfo { + type: StreamProviders name: string summary?: string version?: string state: StreamState - viewers: number + viewers?: number + ingressUrl?: string + ingressKey?: string + balance?: number + publishedEvent?: NostrEvent } + +export class ProviderStore extends ExternalStore> { + #providers: Array = [] + + 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 ManualProvider(), ...this.#providers]; + } + + #save() { + const cfg = this.#providers.map(a => a.createConfig()); + window.localStorage.setItem("providers", JSON.stringify(cfg)); + } +} + +export const StreamProviderStore = new ProviderStore(); \ No newline at end of file diff --git a/src/providers/manual.ts b/src/providers/manual.ts new file mode 100644 index 0000000..a7957da --- /dev/null +++ b/src/providers/manual.ts @@ -0,0 +1,26 @@ +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" + } + info(): Promise { + return Promise.resolve({ + type: StreamProviders.Manual, + name: this.name + } as StreamProviderInfo) + } + + createConfig() { + return { + type: StreamProviders.Manual + } + } + + updateStreamInfo(ev: NostrEvent): Promise { + System.BroadcastEvent(ev); + return Promise.resolve(); + } +} \ No newline at end of file diff --git a/src/providers/nip103.ts b/src/providers/nip103.ts new file mode 100644 index 0000000..0e87a2e --- /dev/null +++ b/src/providers/nip103.ts @@ -0,0 +1,84 @@ +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; + } + + async info() { + const rsp = await this.#getJson("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 { + const title = findTag(ev, "title"); + const summary = findTag(ev, "summary"); + const image = findTag(ev, "image"); + await this.#getJson("PATCH", "event", { + title, summary, image + }); + } + + async #getJson(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise { + 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 + } +} \ No newline at end of file diff --git a/src/providers/owncast.ts b/src/providers/owncast.ts index 3ebca09..7982bed 100644 --- a/src/providers/owncast.ts +++ b/src/providers/owncast.ts @@ -1,3 +1,4 @@ +import { NostrEvent } from "@snort/system"; import { StreamState } from "index"; import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers"; @@ -10,6 +11,10 @@ export class OwncastProvider implements StreamProvider { this.#token = token; } + get name() { + return new URL(this.#url).host + } + createConfig(): any & { type: StreamProviders; } { return { type: StreamProviders.Owncast, @@ -18,10 +23,15 @@ export class OwncastProvider implements StreamProvider { } } + updateStreamInfo(ev: NostrEvent): Promise { + return Promise.resolve(); + } + async info() { const info = await this.#getJson("GET", "/api/config"); const status = await this.#getJson("GET", "/api/status"); return { + type: StreamProviders.Owncast, name: info.name, summary: info.summary, version: info.version, diff --git a/src/utils.ts b/src/utils.ts index 2aaa0a8..43b219e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,3 +40,15 @@ export function splitByUrl(str: string) { 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}`; +} \ No newline at end of file