forked from Kieran/zap.stream
Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
a980080bcb |
BIN
src/cloudflare.png
Normal file
BIN
src/cloudflare.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
@ -224,7 +224,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<Textarea
|
||||
emojis={emojis}
|
||||
value={chat}
|
||||
|
@ -10,7 +10,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-stream div.input {
|
||||
.new-stream div.paper {
|
||||
background: #262626;
|
||||
height: 32px;
|
||||
}
|
||||
|
@ -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>}
|
||||
|
@ -38,7 +38,7 @@
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.send-zap div.input {
|
||||
.send-zap div.paper {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
3
src/element/state-pill.css
Normal file
3
src/element/state-pill.css
Normal file
@ -0,0 +1,3 @@
|
||||
.pill.state {
|
||||
text-transform: uppercase;
|
||||
}
|
6
src/element/state-pill.tsx
Normal file
6
src/element/state-pill.tsx
Normal 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>
|
||||
}
|
@ -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>
|
||||
|
@ -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 |
@ -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;
|
||||
|
@ -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
BIN
src/owncast.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 121 KiB |
@ -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>
|
||||
|
35
src/pages/providers/index.css
Normal file
35
src/pages/providers/index.css
Normal 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;
|
||||
}
|
57
src/pages/providers/index.tsx
Normal file
57
src/pages/providers/index.tsx
Normal 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 />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
86
src/pages/providers/owncast.tsx
Normal file
86
src/pages/providers/owncast.tsx
Normal 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>
|
||||
}
|
@ -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
26
src/providers/index.ts
Normal 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
66
src/providers/owncast.ts
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user