This commit is contained in:
Kieran 2023-06-27 15:06:20 +01:00
parent 9fd8b65bdf
commit a980080bcb
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
20 changed files with 312 additions and 20 deletions

BIN
src/cloudflare.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -224,7 +224,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
return (
<>
<div className="input">
<div className="paper">
<Textarea
emojis={emojis}
value={chat}

View File

@ -10,7 +10,7 @@
margin: 0;
}
.new-stream div.input {
.new-stream div.paper {
background: #262626;
height: 32px;
}

View File

@ -81,7 +81,7 @@ export function NewStream({
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
<div>
<p>Title</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="What are we steaming today?"
@ -92,7 +92,7 @@ export function NewStream({
</div>
<div>
<p>Summary</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="A short description of the content"
@ -103,7 +103,7 @@ export function NewStream({
</div>
<div>
<p>Cover image</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="https://"
@ -114,7 +114,7 @@ export function NewStream({
</div>
<div>
<p>Stream Url</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="https://"
@ -134,7 +134,7 @@ export function NewStream({
</div>
{status === StreamState.Planned && <div>
<p>Start Time</p>
<div className="input">
<div className="paper">
<input type="datetime-local" value={toDateTimeString(Number(start ?? "0"))} onChange={e => setStart(fromDateTimeString(e.target.value).toString())} />
</div>
</div>}

View File

@ -38,7 +38,7 @@
background: #353535;
}
.send-zap div.input {
.send-zap div.paper {
background: #262626;
}

View File

@ -85,7 +85,7 @@ export function SendZaps({ lnurl, ev, targetName, onFinish }: { lnurl: string, e
<small>
Your comment for {name}
</small>
<div className="input">
<div className="paper">
<textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>

View File

@ -0,0 +1,3 @@
.pill.state {
text-transform: uppercase;
}

View File

@ -0,0 +1,6 @@
import "./state-pill.css";
import { StreamState } from "index";
export function StatePill({ state }: { state: StreamState }) {
return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span>
}

View File

@ -3,6 +3,8 @@ import { Profile } from "./profile";
import "./video-tile.css";
import { NostrEvent, encodeTLV, NostrPrefix } from "@snort/system";
import { useInView } from "react-intersection-observer";
import { StatePill } from "./state-pill";
import { StreamState } from "index";
export function VideoTile({ ev }: { ev: NostrEvent }) {
const { inView, ref } = useInView({ triggerOnce: true });
@ -13,13 +15,11 @@ export function VideoTile({ ev }: { ev: NostrEvent }) {
const isLive = status === "live";
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
return <Link to={`/live/${link}`} className="video-tile" ref={ref}>
return <Link to={`/${link}`} className="video-tile" ref={ref}>
<div style={{
backgroundImage: `url(${inView ? image : ""})`
}}>
<span className={`pill${isLive ? " live" : ""}`}>
{status}
</span>
<StatePill state={status as StreamState} />
</div>
<h3>{title}</h3>
<div>

View File

@ -18,6 +18,5 @@
<symbol id="signal" viewBox="0 0 22 18" fill="none">
<path d="M15.2426 4.75735C17.5858 7.1005 17.5858 10.8995 15.2426 13.2426M6.75736 13.2426C4.41421 10.8995 4.41421 7.10046 6.75736 4.75732M3.92893 16.0711C0.0236893 12.1658 0.0236893 5.83417 3.92893 1.92892M18.0711 1.92897C21.9763 5.83421 21.9763 12.1659 18.0711 16.0711M13 8.99999C13 10.1046 12.1046 11 11 11C9.89543 11 9 10.1046 9 8.99999C9 7.89542 9.89543 6.99999 11 6.99999C12.1046 6.99999 13 7.89542 13 8.99999Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -25,6 +25,10 @@ a {
flex-grow: 1;
}
.f-col {
flex-direction: column;
}
.pill {
background: #171717;
padding: 4px 8px;
@ -80,10 +84,11 @@ a {
.btn>span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
input[type="text"], textarea, input[type="datetime-local"] {
input[type="text"], textarea, input[type="datetime-local"], input[type="password"] {
font-family: inherit;
border: unset;
background-color: unset;
@ -94,7 +99,7 @@ input[type="text"], textarea, input[type="datetime-local"] {
outline: none;
}
div.input {
div.paper {
background: #171717;
border-radius: 16px;
padding: 8px 16px;

View File

@ -10,6 +10,7 @@ import { LayoutPage } from "pages/layout";
import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login";
import { StreamProvidersPage } from "pages/providers";
export enum StreamState {
Live = "live",
@ -41,10 +42,18 @@ const router = createBrowserRouter([
path: "/",
element: <RootPage />,
},
{
path: "/:id",
element: <StreamPage />,
},
{
path: "/live/:id",
element: <StreamPage />,
},
{
path: "/providers/:id?",
element: <StreamProvidersPage />,
}
],
},
{

BIN
src/owncast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -72,7 +72,7 @@ export function LayoutPage() {
ev.kind,
ev.pubkey
);
navigate(`/live/${naddr}`);
navigate(`/${naddr}`);
setNewStream(false);
}
@ -80,7 +80,7 @@ export function LayoutPage() {
<>
<header>
<div onClick={() => navigate("/")}>S</div>
<div className="input">
<div className="paper">
<input type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>

View File

@ -0,0 +1,35 @@
.stream-providers-page {
padding: 40px;
}
.stream-providers-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stream-providers-grid>div {
display: flex;
flex-direction: column;
gap: 16px;
}
.stream-providers-grid>div img {
height: 64px;
}
.owncast-config {
display: flex;
gap: 16px;
padding: 40px;
}
.owncast-config>div {
flex: 1;
}
.owncast-config>div:nth-child(2) {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -0,0 +1,57 @@
import "./index.css";
import { StreamProviders } from "providers";
import Owncast from "owncast.png";
import Cloudflare from "cloudflare.png";
import { useNavigate, useParams } from "react-router-dom";
import { ConfigureOwncast } from "./owncast";
export function StreamProvidersPage() {
const navigate = useNavigate();
const { id } = useParams();
function mapName(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return "Owncast"
case StreamProviders.Cloudflare: return "Cloudflare"
}
return "Unknown"
}
function mapLogo(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return <img src={Owncast} />
case StreamProviders.Cloudflare: return <img src={Cloudflare} />
}
}
function providerLink(p: StreamProviders) {
return <div className="paper">
<h3>{mapName(p)}</h3>
{mapLogo(p)}
<button className="btn btn-border" onClick={() => navigate(p)}>
+ Configure
</button>
</div>
}
function index() {
return <div className="stream-providers-page">
<h1>Providers</h1>
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
<div className="stream-providers-grid">
{[StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div>
</div >
}
if (!id) {
return index();
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />
}
}
}
}

View File

@ -0,0 +1,86 @@
import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill";
import { StreamState } from "index";
import { StreamProviderInfo } from "providers";
import { OwncastProvider } from "providers/owncast";
import { useState } from "react";
export function ConfigureOwncast() {
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
async function tryConnect() {
try {
const api = new OwncastProvider(url, token);
const i = await api.info();
setInfo(i);
}
catch (e) {
console.debug(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">
Save
</button>
</div>
</>
}
return <div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Owncast instance url</p>
<div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
</div>
</div>
<div>
<p>API token</p>
<div className="paper">
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
</div>
}

View File

@ -17,6 +17,7 @@ import Modal from "element/modal";
import { SendZaps } from "element/send-zap";
import { useUserProfile } from "@snort/system-react";
import { NewStream } from "element/new-stream";
import { StatePill } from "element/state-pill";
export function StreamPage() {
const params = useParams();
@ -32,7 +33,6 @@ export function StreamPage() {
const status = findTag(thisEvent.data, "status");
const image = findTag(thisEvent.data, "image");
const start = findTag(thisEvent.data, "starts");
const isLive = status === "live";
const isMine = link.author === login?.pubkey;
const zapTarget = profile?.lud16 ?? profile?.lud06;
@ -55,7 +55,7 @@ export function StreamPage() {
<h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p>
<div className="tags">
<span className={`pill${isLive ? " live" : ""}`}>{status}</span>
<StatePill state={status as StreamState} />
{status === StreamState.Planned && <span className="pill">Starts {moment(Number(start) * 1000).fromNow()}</span>}
{thisEvent.data?.tags
.filter((a) => a[0] === "t")

26
src/providers/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { StreamState } from "index"
export interface StreamProvider {
/**
* Get general info about connected provider to test everything is working
*/
info(): Promise<StreamProviderInfo>
/**
* Create a config object to save in localStorage
*/
createConfig(): any & { type: StreamProviders }
}
export enum StreamProviders {
Owncast = "owncast",
Cloudflare = "cloudflare"
}
export interface StreamProviderInfo {
name: string
summary?: string
version?: string
state: StreamState
viewers: number
}

66
src/providers/owncast.ts Normal file
View File

@ -0,0 +1,66 @@
import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class OwncastProvider implements StreamProvider {
#url: string
#token: string
constructor(url: string, token: string) {
this.#url = url;
this.#token = token;
}
createConfig(): any & { type: StreamProviders; } {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token
}
}
async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return {
name: info.name,
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
viewers: status.viewerCount
} as StreamProviderInfo
}
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
"authorization": `Bearer ${this.#token}`
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
}
return JSON.parse(json) as T;
}
}
interface ConfigResponse {
name?: string,
summary?: string,
logo?: string,
tags?: Array<string>,
version?: string
}
interface StatusResponse {
lastConnectTime?: string
lastDisconnectTime?: string
online: boolean
overallMaxViewerCount: number
sessionMaxViewerCount: number
viewerCount: number
}