tmp
This commit is contained in:
parent
f4ea779b0f
commit
5653a6b027
BIN
src/cloudflare.png
Normal file
BIN
src/cloudflare.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
@ -232,7 +232,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<Textarea
|
||||
emojis={emojis}
|
||||
value={chat}
|
||||
|
@ -9,7 +9,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-stream div.input {
|
||||
.new-stream div.paper {
|
||||
background: #262626;
|
||||
height: 32px;
|
||||
}
|
||||
|
@ -84,7 +84,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?"
|
||||
@ -95,7 +95,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"
|
||||
@ -106,7 +106,7 @@ export function NewStream({
|
||||
</div>
|
||||
<div>
|
||||
<p>Cover image</p>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
@ -117,7 +117,7 @@ export function NewStream({
|
||||
</div>
|
||||
<div>
|
||||
<p>Stream Url</p>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://"
|
||||
|
@ -37,7 +37,7 @@
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.send-zap div.input {
|
||||
.send-zap div.paper {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
|
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 });
|
||||
@ -17,9 +19,7 @@ export function VideoTile({ ev }: { ev: NostrEvent }) {
|
||||
<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,6 +42,10 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <RootPage />,
|
||||
},
|
||||
{
|
||||
path: "/:id",
|
||||
element: <StreamPage />,
|
||||
},
|
||||
{
|
||||
path: "/live/:id",
|
||||
element: <StreamPage />,
|
||||
@ -49,6 +54,10 @@ const router = createBrowserRouter([
|
||||
path: "/: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 |
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>
|
||||
}
|
@ -16,6 +16,7 @@ import { SendZapsDialog } from "element/send-zap";
|
||||
import type { NostrLink } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { NewStreamDialog } from "element/new-stream";
|
||||
import { StatePill } from "element/state-pill";
|
||||
|
||||
function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
const thisEvent = useEventFeed(link, true);
|
||||
@ -26,7 +27,6 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
|
||||
const status = findTag(thisEvent.data, "status");
|
||||
const start = findTag(thisEvent.data, "starts");
|
||||
const isLive = status === "live";
|
||||
const isMine = link.author === login?.pubkey;
|
||||
|
||||
async function deleteStream() {
|
||||
@ -46,7 +46,7 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
<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()}
|
||||
|
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…
x
Reference in New Issue
Block a user