forked from Kieran/zap.stream
Merge branch 'main' into profile
This commit is contained in:
commit
b567a2515e
BIN
src/cloudflare.png
Normal file
BIN
src/cloudflare.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
@ -217,7 +217,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;
|
||||
}
|
||||
|
||||
|
@ -1,24 +1,30 @@
|
||||
import "./send-zap.css";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useEffect, useState, ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { LNURL } from "@snort/shared";
|
||||
import { NostrEvent, EventPublisher } from "@snort/system";
|
||||
import { formatSats } from "../number";
|
||||
import { Icon } from "./icon";
|
||||
import AsyncButton from "./async-button";
|
||||
import { findTag } from "utils";
|
||||
import { Relays } from "index";
|
||||
import QrCode from "./qr-code";
|
||||
|
||||
interface SendZapsProps {
|
||||
lnurl: string;
|
||||
ev?: NostrEvent;
|
||||
pubkey?: string;
|
||||
aTag?: string;
|
||||
targetName?: string;
|
||||
onFinish: () => void;
|
||||
button?: ReactNode;
|
||||
}
|
||||
|
||||
function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
||||
function SendZaps({
|
||||
lnurl,
|
||||
pubkey,
|
||||
aTag,
|
||||
targetName,
|
||||
onFinish,
|
||||
}: SendZapsProps) {
|
||||
const UsdRate = 30_000;
|
||||
|
||||
const satsAmounts = [
|
||||
@ -51,15 +57,15 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
||||
|
||||
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
|
||||
let zap: NostrEvent | undefined;
|
||||
if (ev) {
|
||||
if (pubkey && aTag) {
|
||||
zap = await pub.zap(
|
||||
amountInSats * 1000,
|
||||
ev.pubkey,
|
||||
pubkey,
|
||||
Relays,
|
||||
undefined,
|
||||
comment,
|
||||
(eb) => {
|
||||
return eb.tag(["a", `${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`]);
|
||||
return eb.tag(["a", aTag]);
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -115,7 +121,7 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
||||
</div>
|
||||
<div>
|
||||
<small>Your comment for {name}</small>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<textarea
|
||||
placeholder="Nice!"
|
||||
value={comment}
|
||||
@ -151,18 +157,13 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SendZapsDialog({
|
||||
lnurl,
|
||||
ev,
|
||||
targetName,
|
||||
button,
|
||||
}: Omit<SendZapsProps, "onFinish">) {
|
||||
export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
{button ? (
|
||||
button
|
||||
{props.button ? (
|
||||
props.button
|
||||
) : (
|
||||
<button className="btn btn-primary zap">
|
||||
<span className="hide-on-mobile">Zap</span>
|
||||
@ -173,12 +174,7 @@ export function SendZapsDialog({
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<SendZaps
|
||||
lnurl={lnurl}
|
||||
ev={ev}
|
||||
targetName={targetName}
|
||||
onFinish={() => setIsOpen(false)}
|
||||
/>
|
||||
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
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>
|
||||
}
|
@ -10,7 +10,8 @@ export function Tags({ ev }: { ev: TaggedRawEvent }) {
|
||||
<div className="tags">
|
||||
{status === StreamState.Planned && (
|
||||
<span className="pill">
|
||||
Starts {moment(Number(start) * 1000).fromNow()}
|
||||
{status === StreamState.Planned ? "Starts " : ""}
|
||||
{moment(Number(start) * 1000).fromNow()}
|
||||
</span>
|
||||
)}
|
||||
{ev.tags
|
||||
|
@ -3,27 +3,42 @@ 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 });
|
||||
const id = ev.tags.find(a => a[0] === "d")?.[1]!;
|
||||
const title = ev.tags.find(a => a[0] === "title")?.[1];
|
||||
const image = ev.tags.find(a => a[0] === "image")?.[1];
|
||||
const status = ev.tags.find(a => a[0] === "status")?.[1];
|
||||
const isLive = status === "live";
|
||||
export function VideoTile({
|
||||
ev,
|
||||
showAuthor = true,
|
||||
}: {
|
||||
ev: NostrEvent;
|
||||
showAuthor?: boolean;
|
||||
}) {
|
||||
const { inView, ref } = useInView({ triggerOnce: true });
|
||||
const id = ev.tags.find((a) => a[0] === "d")?.[1]!;
|
||||
const title = ev.tags.find((a) => a[0] === "title")?.[1];
|
||||
const image = ev.tags.find((a) => a[0] === "image")?.[1];
|
||||
const status = ev.tags.find((a) => a[0] === "status")?.[1];
|
||||
const host =
|
||||
ev.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
|
||||
|
||||
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
|
||||
return <Link to={`/${link}`} className="video-tile" ref={ref}>
|
||||
<div style={{
|
||||
backgroundImage: `url(${inView ? image : ""})`
|
||||
}}>
|
||||
<span className={`pill${isLive ? " live" : ""}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<div>
|
||||
{inView && <Profile pubkey={ev.pubkey} />}
|
||||
</div>
|
||||
const link = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
id,
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return (
|
||||
<Link to={`/${link}`} className="video-tile" ref={ref}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${inView ? image : ""})`,
|
||||
}}
|
||||
>
|
||||
<StatePill state={status as StreamState} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
||||
</Link>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -21,6 +21,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: 4.4 KiB After Width: | Height: | Size: 4.4 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;
|
||||
|
@ -11,6 +11,7 @@ import { ProfilePage } from "pages/profile-page";
|
||||
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",
|
||||
@ -47,12 +48,12 @@ const router = createBrowserRouter([
|
||||
element: <ProfilePage />,
|
||||
},
|
||||
{
|
||||
path: "/:id",
|
||||
path: "/live/:id",
|
||||
element: <StreamPage />,
|
||||
},
|
||||
{
|
||||
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 |
@ -102,7 +102,7 @@ header .logo {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header .input {
|
||||
header .paper {
|
||||
min-width: 300px;
|
||||
height: 32px;
|
||||
}
|
||||
@ -134,11 +134,11 @@ header .profile img {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
header .input {
|
||||
header .paper {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
header .input .search-input {
|
||||
header .paper .search-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -81,7 +81,7 @@ export function LayoutPage() {
|
||||
<div className="logo" onClick={() => navigate("/")}>
|
||||
S
|
||||
</div>
|
||||
<div className="input">
|
||||
<div className="paper">
|
||||
<input className="search-input" type="text" placeholder="Search" />
|
||||
<Icon name="search" size={15} />
|
||||
</div>
|
||||
|
@ -151,7 +151,7 @@
|
||||
|
||||
.tabs-content {
|
||||
flex-grow: 1;
|
||||
padding-top: 6px;
|
||||
padding: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
outline: none;
|
||||
|
@ -78,7 +78,7 @@ export function ProfilePage() {
|
||||
liveEvent.kind,
|
||||
liveEvent.pubkey
|
||||
);
|
||||
navigate(`/${naddr}`);
|
||||
navigate(`/live/${naddr}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,7 +111,14 @@ export function ProfilePage() {
|
||||
<div className="profile-actions">
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
ev={liveEvent}
|
||||
aTag={
|
||||
liveEvent
|
||||
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
|
||||
liveEvent,
|
||||
"d"
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
@ -158,7 +165,7 @@ export function ProfilePage() {
|
||||
<div className="stream-list">
|
||||
{pastStreams.map((ev) => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} />
|
||||
<VideoTile ev={ev} showAuthor={false} />
|
||||
<Tags ev={ev} />
|
||||
</div>
|
||||
))}
|
||||
@ -168,7 +175,7 @@ export function ProfilePage() {
|
||||
<div className="stream-list">
|
||||
{futureStreams.map((ev) => (
|
||||
<div key={ev.id} className="stream-item">
|
||||
<VideoTile ev={ev} />
|
||||
<VideoTile ev={ev} showAuthor={false} />
|
||||
<Tags ev={ev} />
|
||||
</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>
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import "./stream-page.css";
|
||||
import { useRef } from "react";
|
||||
import { parseNostrLink, EventPublisher } from "@snort/system";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@ -10,20 +9,25 @@ import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { System } from "index";
|
||||
import { StreamState, System } from "index";
|
||||
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 { Tags } from "element/tags";
|
||||
import { StatePill } from "element/state-pill";
|
||||
|
||||
function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
const thisEvent = useEventFeed(link, true);
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const profile = useUserProfile(System, thisEvent.data?.pubkey);
|
||||
const host =
|
||||
thisEvent.data?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
|
||||
thisEvent.data?.pubkey;
|
||||
const profile = useUserProfile(System, host);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
const status = thisEvent?.data ? findTag(thisEvent.data, "status") : "";
|
||||
const isMine = link.author === login?.pubkey;
|
||||
|
||||
async function deleteStream() {
|
||||
@ -42,6 +46,7 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
<div className="f-grow stream-info">
|
||||
<h1>{findTag(thisEvent.data, "title")}</h1>
|
||||
<p>{findTag(thisEvent.data, "summary")}</p>
|
||||
<StatePill state={status as StreamState} />
|
||||
{thisEvent?.data && <Tags ev={thisEvent.data} />}
|
||||
{isMine && (
|
||||
<div className="actions">
|
||||
@ -59,11 +64,15 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info flex g24">
|
||||
<Profile pubkey={thisEvent.data?.pubkey ?? ""} />
|
||||
<Profile pubkey={host ?? ""} />
|
||||
{zapTarget && thisEvent.data && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
ev={thisEvent.data}
|
||||
pubkey={host}
|
||||
aTag={`${thisEvent.data.kind}:${thisEvent.data.pubkey}:${findTag(
|
||||
thisEvent.data,
|
||||
"d"
|
||||
)}`}
|
||||
targetName={getName(thisEvent.data.pubkey, profile)}
|
||||
/>
|
||||
)}
|
||||
@ -86,13 +95,11 @@ function VideoPlayer({ link }: { link: NostrLink }) {
|
||||
}
|
||||
|
||||
export function StreamPage() {
|
||||
const ref = useRef(null);
|
||||
const params = useParams();
|
||||
const link = parseNostrLink(params.id!);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref}></div>
|
||||
<VideoPlayer link={link} />
|
||||
<ProfileInfo link={link} />
|
||||
<LiveChat link={link} />
|
||||
|
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