feat: better clips
This commit is contained in:
parent
3d21e1ca19
commit
565de1a19e
@ -14,6 +14,12 @@ export const MUTED = 10_000 as EventKind;
|
||||
export const DAY = 60 * 60 * 24;
|
||||
export const WEEK = 7 * DAY;
|
||||
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
Ended = "ended",
|
||||
Planned = "planned",
|
||||
}
|
||||
|
||||
export const defaultRelays = {
|
||||
"wss://relay.snort.social": { read: true, write: true },
|
||||
"wss://nos.lol": { read: true, write: true },
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "./async-button.css";
|
||||
import { useState } from "react";
|
||||
import { forwardRef, useState } from "react";
|
||||
import Spinner from "./spinner";
|
||||
import classNames from "classnames";
|
||||
|
||||
@ -9,7 +9,7 @@ interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AsyncButton(props: AsyncButtonProps) {
|
||||
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function handle(e: React.MouseEvent) {
|
||||
@ -17,11 +17,8 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
if (loading || props.disabled) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (typeof props.onClick === "function") {
|
||||
const f = props.onClick(e);
|
||||
if (f instanceof Promise) {
|
||||
await f;
|
||||
}
|
||||
if (props.onClick) {
|
||||
await props.onClick(e);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -30,11 +27,16 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
onClick={handle}
|
||||
className={classNames("px-3 py-2 bg-gray-2 rounded-full", props.className)}>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
|
||||
<span
|
||||
style={{ visibility: loading ? "hidden" : "visible" }}
|
||||
className="whitespace-nowrap flex gap-2 items-center justify-center">
|
||||
{props.children}
|
||||
</span>
|
||||
{loading && (
|
||||
<span className="spinner-wrapper">
|
||||
<Spinner />
|
||||
@ -42,4 +44,5 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
export default AsyncButton;
|
||||
|
@ -16,6 +16,7 @@ import { CollapsibleEvent } from "./collapsible";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { formatSats } from "@/number";
|
||||
import type { Badge, Emoji, EmojiPack } from "@/types";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
function emojifyReaction(reaction: string) {
|
||||
if (reaction === "+") {
|
||||
@ -193,20 +194,20 @@ export function ChatMessage({
|
||||
eTag={ev.id}
|
||||
pubkey={ev.pubkey}
|
||||
button={
|
||||
<button className="message-zap-button">
|
||||
<AsyncButton className="message-zap-button">
|
||||
<Icon name="zap" className="message-zap-button-icon" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
}
|
||||
targetName={profile?.name || ev.pubkey}
|
||||
/>
|
||||
)}
|
||||
<button className="message-zap-button" onClick={pickEmoji}>
|
||||
<AsyncButton className="message-zap-button" onClick={pickEmoji}>
|
||||
<Icon name="face" className="message-zap-button-icon" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
{shouldShowMuteButton && (
|
||||
<button className="message-zap-button" onClick={muteUser}>
|
||||
<AsyncButton className="message-zap-button" onClick={muteUser}>
|
||||
<Icon name="user-x" className="message-zap-button-icon" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,47 +1,116 @@
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NostrStreamProvider } from "@/providers";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import AsyncButton from "./async-button";
|
||||
import { LIVE_STREAM_CLIP } from "@/const";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { useContext } from "react";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import AsyncButton from "./async-button";
|
||||
import { LIVE_STREAM_CLIP, StreamState } from "@/const";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { Icon } from "./icon";
|
||||
import { StreamState } from "..";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { TimelineBar } from "./timeline";
|
||||
|
||||
export function ClipButton({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const system = useContext(SnortContext);
|
||||
const { id, service, status } = extractStreamInfo(ev);
|
||||
const ref = useRef<HTMLVideoElement | null>(null);
|
||||
const login = useLogin();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tempClipId, setTempClipId] = useState<string>();
|
||||
const [start, setStart] = useState(0);
|
||||
const [length, setLength] = useState(0.1);
|
||||
const [clipLength, setClipLength] = useState(0);
|
||||
|
||||
const publisher = login?.publisher();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.currentTime = clipLength * start;
|
||||
}
|
||||
}, [ref.current, clipLength, start, length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.ontimeupdate = () => {
|
||||
if (!ref.current) return;
|
||||
console.debug(ref.current.currentTime);
|
||||
const end = clipLength * (start + length);
|
||||
if (ref.current.currentTime >= end) {
|
||||
ref.current.pause();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [ref.current, clipLength, start, length]);
|
||||
|
||||
if (!service || status !== StreamState.Live) return;
|
||||
const provider = new NostrStreamProvider("", service, publisher);
|
||||
|
||||
async function makeClip() {
|
||||
if (!service || !id) return;
|
||||
const publisher = login?.publisher();
|
||||
if (!publisher) return;
|
||||
if (!service || !id || !publisher) return;
|
||||
|
||||
const provider = new NostrStreamProvider("", service, publisher);
|
||||
const clip = await provider.createClip(id);
|
||||
const clip = await provider.prepareClip(id);
|
||||
console.debug(clip);
|
||||
|
||||
setTempClipId(clip.id);
|
||||
setClipLength(clip.length);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function saveClip() {
|
||||
if (!service || !id || !publisher || !tempClipId) return;
|
||||
|
||||
const newClip = await provider.createClip(id, tempClipId, clipLength * start, clipLength * length);
|
||||
const ee = await publisher.generic(eb => {
|
||||
return eb
|
||||
.kind(LIVE_STREAM_CLIP)
|
||||
.tag(unwrap(NostrLink.fromEvent(ev).toEventTag("root")))
|
||||
.tag(["r", clip.url])
|
||||
.tag(["alt", `Live stream clip created on https://zap.stream\n${clip.url}`]);
|
||||
.tag(["r", newClip.url])
|
||||
.tag(["alt", `Live stream clip created on https://zap.stream\n${newClip.url}`]);
|
||||
});
|
||||
console.debug(ee);
|
||||
await system.BroadcastEvent(ee);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncButton onClick={makeClip} className="btn btn-primary">
|
||||
<Icon name="clapperboard" />
|
||||
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
|
||||
</AsyncButton>
|
||||
<>
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<div className="contents">
|
||||
<AsyncButton onClick={makeClip} className="btn btn-primary">
|
||||
<Icon name="clapperboard" />
|
||||
<span className="max-lg:hidden">
|
||||
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
|
||||
</span>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<div className="content-inner">
|
||||
<h1>
|
||||
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
|
||||
</h1>
|
||||
{id && tempClipId && <video ref={ref} src={provider.getTempClipUrl(id, tempClipId)} controls muted />}
|
||||
<TimelineBar
|
||||
length={length}
|
||||
offset={start}
|
||||
width={300}
|
||||
height={60}
|
||||
setOffset={setStart}
|
||||
setLength={setLength}
|
||||
/>
|
||||
<AsyncButton onClick={saveClip}>
|
||||
<FormattedMessage defaultMessage="Publish Clip" id="jJLRgo" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import { Mention } from "./mention";
|
||||
import { EventIcon, NostrEvent } from "./Event";
|
||||
import { ExternalLink } from "./external-link";
|
||||
import { useEvent } from "@/hooks/event";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
interface MediaURLProps {
|
||||
url: URL;
|
||||
@ -31,9 +32,9 @@ export function MediaURL({ url, children }: MediaURLProps) {
|
||||
{children}
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="btn delete-button" aria-label="Close">
|
||||
<AsyncButton className="btn delete-button" aria-label="Close">
|
||||
<FormattedMessage defaultMessage="Close" id="rbrahO" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
@ -54,13 +55,13 @@ export function CollapsibleEvent({ link }: { link: NostrLink }) {
|
||||
{author && <Mention pubkey={author} />}
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
|
||||
<AsyncButton className={`${open ? "btn btn-small delete-button" : "btn btn-small"}`}>
|
||||
{open ? (
|
||||
<FormattedMessage defaultMessage="Hide" id="VA/Z1S" />
|
||||
) : (
|
||||
<FormattedMessage defaultMessage="Show" id="K7AkdL" />
|
||||
)}
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>{open && event && <NostrEvent ev={event} />}</Collapsible.Content>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
export function isContentWarningAccepted() {
|
||||
return Boolean(window.localStorage.getItem("accepted-content-warning"));
|
||||
@ -25,12 +26,12 @@ export function ContentWarningOverlay() {
|
||||
<FormattedMessage defaultMessage="Confirm your age" id="s7V+5p" />
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<button className="btn btn-warning" onClick={grownUp}>
|
||||
<AsyncButton className="btn btn-warning" onClick={grownUp}>
|
||||
<FormattedMessage defaultMessage="Yes, I am over 18" id="O2Cy6m" />
|
||||
</button>
|
||||
<button className="btn" onClick={() => navigate("/")}>
|
||||
</AsyncButton>
|
||||
<AsyncButton className="btn" onClick={() => navigate("/")}>
|
||||
<FormattedMessage defaultMessage="No, I am under 18" id="KkIL3s" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ import type { ChangeEvent } from "react";
|
||||
import { VoidApi } from "@void-cat/api";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
const voidCatHost = "https://void.cat";
|
||||
const fileExtensionRegex = /\.([\w]{1,7})$/i;
|
||||
@ -13,7 +14,7 @@ type UploadResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
|
||||
async function voidCatUpload(file: File): Promise<UploadResult> {
|
||||
const uploader = voidCatApi.getUploader(file);
|
||||
|
||||
const rsp = await uploader.upload({
|
||||
@ -86,9 +87,9 @@ export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUpload
|
||||
</label>
|
||||
<div className="file-uploader-preview">
|
||||
{img?.length > 0 && (
|
||||
<button className="btn btn-primary clear-button" onClick={clearImage}>
|
||||
<AsyncButton className="btn btn-primary clear-button" onClick={clearImage}>
|
||||
<FormattedMessage defaultMessage="Clear" id="/GCoTA" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
)}
|
||||
{img && <img className="image-preview" src={img} />}
|
||||
</div>
|
||||
|
@ -7,7 +7,15 @@ import { useLogin } from "@/hooks/login";
|
||||
import AsyncButton from "./async-button";
|
||||
import { Login } from "@/index";
|
||||
|
||||
export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: string }) {
|
||||
export function LoggedInFollowButton({
|
||||
tag,
|
||||
value,
|
||||
hideWhenFollowing,
|
||||
}: {
|
||||
tag: "p" | "t";
|
||||
value: string;
|
||||
hideWhenFollowing?: boolean;
|
||||
}) {
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
if (!login) return;
|
||||
@ -50,6 +58,7 @@ export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: st
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowing && hideWhenFollowing) return;
|
||||
return (
|
||||
<AsyncButton
|
||||
disabled={timestamp ? timestamp === 0 : true}
|
||||
@ -65,12 +74,12 @@ export function LoggedInFollowButton({ tag, value }: { tag: "p" | "t"; value: st
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowTagButton({ tag }: { tag: string }) {
|
||||
export function FollowTagButton({ tag, hideWhenFollowing }: { tag: string; hideWhenFollowing?: boolean }) {
|
||||
const login = useLogin();
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} /> : null;
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} hideWhenFollowing={hideWhenFollowing} /> : null;
|
||||
}
|
||||
|
||||
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
export function FollowButton({ pubkey, hideWhenFollowing }: { pubkey: string; hideWhenFollowing?: boolean }) {
|
||||
const login = useLogin();
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} /> : null;
|
||||
return login?.pubkey ? <LoggedInFollowButton tag={"p"} value={pubkey} hideWhenFollowing={hideWhenFollowing} /> : null;
|
||||
}
|
||||
|
@ -2,10 +2,10 @@
|
||||
import Hls from "hls.js";
|
||||
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { StreamState } from "..";
|
||||
import { Icon } from "./icon";
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export enum VideoStatus {
|
||||
Online = "online",
|
||||
@ -13,6 +13,7 @@ export enum VideoStatus {
|
||||
}
|
||||
|
||||
type VideoPlayerProps = {
|
||||
title?: string;
|
||||
stream?: string;
|
||||
status?: string;
|
||||
poster?: string;
|
||||
@ -20,6 +21,7 @@ type VideoPlayerProps = {
|
||||
} & HTMLProps<HTMLVideoElement>;
|
||||
|
||||
export default function LiveVideoPlayer({
|
||||
title,
|
||||
stream,
|
||||
status: pStatus,
|
||||
poster,
|
||||
@ -169,11 +171,17 @@ export default function LiveVideoPlayer({
|
||||
<>
|
||||
{status === VideoStatus.Online && (
|
||||
<div
|
||||
className="absolute opacity-0 hover:opacity-90 transition-opacity w-full h-full z-20 bg-[#00000055]"
|
||||
className="absolute opacity-0 hover:opacity-90 transition-opacity w-full h-full z-20 bg-[#00000055] select-none"
|
||||
onClick={() => togglePlay()}>
|
||||
{/* TITLE */}
|
||||
<div className="absolute top-2 w-full text-center">
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{/* CENTER PLAY ICON */}
|
||||
<div className="absolute w-full h-full flex items-center justify-center pointer">
|
||||
<Icon name={playStateToIcon()} size={80} className={playState === "loading" ? "animate-spin" : ""} />
|
||||
</div>
|
||||
{/* PLAYER CONTROLS OVERLAY */}
|
||||
<div
|
||||
className="absolute flex items-center gap-1 bottom-0 w-full bg-primary h-[40px]"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
@ -163,9 +163,9 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="No emails, just awesomeness!" id="+AcVD+" />
|
||||
</h3>
|
||||
<button type="button" className="btn btn-primary btn-block" onClick={createAccount}>
|
||||
<AsyncButton className="btn btn-primary btn-block" onClick={createAccount}>
|
||||
<FormattedMessage defaultMessage="Create Account" id="5JcXdV" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
|
||||
<div className="or-divider">
|
||||
<hr />
|
||||
@ -174,14 +174,14 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
</div>
|
||||
{hasNostrExtension && (
|
||||
<>
|
||||
<AsyncButton type="button" className="btn btn-primary btn-block" onClick={loginNip7}>
|
||||
<AsyncButton className="btn btn-primary btn-block" onClick={loginNip7}>
|
||||
<FormattedMessage defaultMessage="Nostr Extension" id="ebmhes" />
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary btn-block" onClick={() => setStage(Stage.LoginInput)}>
|
||||
<AsyncButton className="btn btn-primary btn-block" onClick={() => setStage(Stage.LoginInput)}>
|
||||
<FormattedMessage defaultMessage="Login with Private Key (insecure)" id="feZ/kG" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
{error && <b className="error">{error}</b>}
|
||||
</div>
|
||||
</>
|
||||
@ -219,15 +219,14 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
<div className="flex justify-between">
|
||||
<div></div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
<AsyncButton
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setNewKey("");
|
||||
setStage(Stage.Login);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
<AsyncButton onClick={doLoginNsec} className="btn btn-primary">
|
||||
<FormattedMessage defaultMessage="Log In" id="r2Jjms" />
|
||||
</AsyncButton>
|
||||
@ -341,9 +340,9 @@ export function LoginSignup({ close }: { close: () => void }) {
|
||||
<div className="paper">
|
||||
<Copy text={hexToBech32("nsec", key)} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
|
||||
<AsyncButton className="btn btn-primary" onClick={loginWithKey}>
|
||||
<FormattedMessage defaultMessage="Ok, it's safe" id="My6HwN" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -40,14 +40,14 @@ export function NewGoalDialog() {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" className="btn btn-primary">
|
||||
<AsyncButton className="btn btn-primary">
|
||||
<span>
|
||||
<Icon name="zap-filled" size={12} />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
|
@ -12,6 +12,7 @@ import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/provider
|
||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||
import { eventLink, findTag } from "@/utils";
|
||||
import { NostrProviderDialog } from "./nostr-provider-dialog";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
|
||||
const system = useContext(SnortContext);
|
||||
@ -56,14 +57,14 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
|
||||
case StreamProviders.NostrType: {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<AsyncButton
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
navigate("/settings");
|
||||
onFinish?.();
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Get stream key" id="KdYELp" />
|
||||
</button>
|
||||
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />
|
||||
</AsyncButton>
|
||||
<NostrProviderDialog
|
||||
provider={currentProvider as NostrStreamProvider}
|
||||
onFinish={onFinish}
|
||||
@ -108,17 +109,17 @@ export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps)
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" className={props.btnClassName}>
|
||||
<AsyncButton className={props.btnClassName}>
|
||||
{props.text && props.text}
|
||||
{!props.text && (
|
||||
<>
|
||||
<span className="hide-on-mobile">
|
||||
<span className="max-xl:hidden">
|
||||
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
|
||||
</span>
|
||||
<Icon name="signal" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
|
@ -165,9 +165,11 @@ export function NostrProviderDialog({
|
||||
<div className="paper grow">
|
||||
<input type="password" value={ep?.key} disabled />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
|
||||
<AsyncButton
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
|
||||
<FormattedMessage defaultMessage="Copy" id="4l6vz1" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -182,9 +184,9 @@ export function NostrProviderDialog({
|
||||
values={{ amount: info.balance?.toLocaleString() }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||
<AsyncButton className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||
<FormattedMessage defaultMessage="Topup" id="nBCvvJ" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
<small>
|
||||
<FormattedMessage defaultMessage="About {estimate}" id="Q3au2v" values={{ estimate: calcEstimate() }} />
|
||||
|
@ -34,10 +34,6 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.send-zap .btn > span {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send-zap .qr {
|
||||
align-self: center;
|
||||
}
|
||||
|
@ -185,9 +185,9 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
|
||||
<div className="flex items-center">
|
||||
<Copy text={invoice} />
|
||||
</div>
|
||||
<button className="btn btn-primary wide" onClick={() => onFinish()}>
|
||||
<AsyncButton className="btn btn-primary wide" onClick={() => onFinish()}>
|
||||
<FormattedMessage defaultMessage="Back" id="cyR7Kh" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -212,12 +212,12 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
|
||||
{props.button ? (
|
||||
props.button
|
||||
) : (
|
||||
<button className="btn btn-primary zap">
|
||||
<span className="hide-on-mobile">
|
||||
<AsyncButton className="btn btn-primary zap">
|
||||
<span className="max-xl:hidden">
|
||||
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
||||
</span>
|
||||
<Icon name="zap-filled" size={16} />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
)}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
|
@ -40,9 +40,9 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
|
||||
gap={5}
|
||||
menuClassName="ctx-menu"
|
||||
menuButton={
|
||||
<button type="button" className="btn btn-secondary">
|
||||
<AsyncButton className="btn btn-secondary">
|
||||
<FormattedMessage defaultMessage="Share" id="OKhRC6" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { HTMLProps } from "react";
|
||||
import "./state-pill.css";
|
||||
import { StreamState } from "@/index";
|
||||
import classNames from "classnames";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
type StatePillProps = { state: StreamState } & HTMLProps<HTMLSpanElement>;
|
||||
|
||||
|
@ -21,6 +21,7 @@ import { CARD, USER_CARDS } from "@/const";
|
||||
import { findTag } from "@/utils";
|
||||
import { Login } from "@/index";
|
||||
import type { Tags } from "@/types";
|
||||
import AsyncButton from "./async-button";
|
||||
|
||||
interface CardType {
|
||||
identifier: string;
|
||||
@ -244,12 +245,12 @@ function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDial
|
||||
</span>
|
||||
</div>
|
||||
<div className="new-card-buttons">
|
||||
<button className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
|
||||
<AsyncButton className="btn btn-primary add-button" onClick={() => onSave({ title, image, content, link })}>
|
||||
{cta || <FormattedMessage defaultMessage="Add Card" id="UJBFYK" />}
|
||||
</button>
|
||||
<button className="btn delete-button" onClick={onCancel}>
|
||||
</AsyncButton>
|
||||
<AsyncButton className="btn delete-button" onClick={onCancel}>
|
||||
{cancelCta || <FormattedMessage defaultMessage="Cancel" id="47FYwb" />}
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -312,9 +313,9 @@ function EditCard({ card, cards }: EditCardProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="btn btn-primary">
|
||||
<AsyncButton className="btn btn-primary">
|
||||
<FormattedMessage defaultMessage="Edit" id="wEQDC6" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
|
@ -6,11 +6,11 @@ import { TagsInput } from "react-tag-input-component";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import AsyncButton from "./async-button";
|
||||
import { StreamState } from "@/index";
|
||||
import { extractStreamInfo, findTag } from "@/utils";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { NewGoalDialog } from "./new-goal";
|
||||
import { useGoals } from "@/hooks/goals";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export interface StreamEditorProps {
|
||||
ev?: NostrEvent;
|
||||
|
@ -1,15 +1,14 @@
|
||||
import { LIVE_STREAM_CHAT } from "@/const";
|
||||
import { LIVE_STREAM_CHAT, StreamState } from "@/const";
|
||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||
import { useLiveChatFeed } from "@/hooks/live-chat";
|
||||
import { formatSats } from "@/number";
|
||||
import { extractStreamInfo, findTag } from "@/utils";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { NostrLink, NostrEvent, ParsedZap, EventKind } from "@snort/system";
|
||||
import { useEventReactions } from "@snort/system-react";
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage, FormattedNumber, FormattedDate } from "react-intl";
|
||||
import { ResponsiveContainer, BarChart, XAxis, YAxis, Bar, Tooltip } from "recharts";
|
||||
import { StreamState } from "..";
|
||||
import { Profile } from "./profile";
|
||||
import { StatePill } from "./state-pill";
|
||||
|
||||
|
@ -2,9 +2,8 @@ import type { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { NostrEvent } from "@snort/system";
|
||||
|
||||
import { StreamState } from "@/index";
|
||||
import { findTag, getTagValues } from "@/utils";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
|
||||
const status = findTag(ev, "status");
|
||||
|
164
src/element/timeline.tsx
Normal file
164
src/element/timeline.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { HTMLProps, useRef, useEffect } from "react";
|
||||
|
||||
type TimelineProps = {
|
||||
length: number;
|
||||
offset: number;
|
||||
setLength: (n: number) => void;
|
||||
setOffset: (n: number) => void;
|
||||
} & Omit<HTMLProps<HTMLCanvasElement>, "ref">;
|
||||
|
||||
export function TimelineBar({
|
||||
length: pLength,
|
||||
offset: pOffset,
|
||||
setLength: pSetLength,
|
||||
setOffset: pSetOffset,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
function setupHandler(canvas: HTMLCanvasElement) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let draggingOffset = false;
|
||||
let draggingLength = false;
|
||||
let offset = pOffset;
|
||||
let length = pLength;
|
||||
|
||||
function getBodyRect() {
|
||||
const x = canvas.width * offset;
|
||||
const w = Math.max(10, canvas.width * length);
|
||||
return {
|
||||
x,
|
||||
y: 0,
|
||||
w,
|
||||
h: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getDragHandleRect() {
|
||||
const x = canvas.width * (offset + length);
|
||||
const w = 5;
|
||||
return {
|
||||
x,
|
||||
y: 0,
|
||||
w,
|
||||
h: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.strokeRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const drawBody = () => {
|
||||
const { x, y, w, h } = getBodyRect();
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
};
|
||||
const drawHandle = () => {
|
||||
const { x, y, w, h } = getDragHandleRect();
|
||||
ctx.fillStyle = "#ccc";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
};
|
||||
|
||||
drawBody();
|
||||
drawHandle();
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function scaleX(x: number) {
|
||||
return (x / rect.width) * canvas.width;
|
||||
}
|
||||
|
||||
function getEventLocation(event: MouseEvent | TouchEvent): { x: number } {
|
||||
if (event instanceof TouchEvent) {
|
||||
return {
|
||||
x: scaleX(event.touches[0].clientX - rect.x),
|
||||
};
|
||||
} else {
|
||||
// MouseEvent
|
||||
return {
|
||||
x: scaleX(event.clientX - rect.x),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function xOfBody(x: number) {
|
||||
const { w } = getBodyRect();
|
||||
return Math.min(1 - length, Math.max(0, (x - w / 2) / canvas.width));
|
||||
}
|
||||
|
||||
function xOfHandle(x: number) {
|
||||
const { w } = getDragHandleRect();
|
||||
return Math.min(1, Math.max(0.1, (x - w / 2) / canvas.width - offset));
|
||||
}
|
||||
|
||||
function handleStart(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
const { x } = getEventLocation(event);
|
||||
const body = getBodyRect();
|
||||
if (x >= body.x && x <= body.x + body.w) {
|
||||
draggingOffset = true;
|
||||
console.debug("dragging offset");
|
||||
}
|
||||
const handle = getDragHandleRect();
|
||||
if (x >= handle.x && x <= handle.x + handle.w) {
|
||||
draggingLength = true;
|
||||
console.debug("dragging length");
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
const { x } = getEventLocation(event);
|
||||
if (draggingLength) {
|
||||
const newVal = xOfHandle(x);
|
||||
length = newVal;
|
||||
} else if (draggingOffset) {
|
||||
const newVal = xOfBody(x);
|
||||
offset = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
const { x } = getEventLocation(event);
|
||||
console.debug("drag end");
|
||||
if (draggingLength) {
|
||||
const newVal = xOfHandle(x);
|
||||
pSetLength(newVal);
|
||||
} else if (draggingOffset) {
|
||||
const newVal = xOfBody(x);
|
||||
pSetOffset(newVal);
|
||||
}
|
||||
draggingLength = false;
|
||||
draggingOffset = false;
|
||||
}
|
||||
|
||||
// Add mouse event listeners
|
||||
canvas.addEventListener("mousedown", handleStart);
|
||||
canvas.addEventListener("mousemove", handleMove);
|
||||
canvas.addEventListener("mouseup", handleEnd);
|
||||
canvas.addEventListener("mouseleave", handleEnd);
|
||||
|
||||
// Add touch event listeners
|
||||
canvas.addEventListener("touchstart", handleStart);
|
||||
canvas.addEventListener("touchmove", handleMove);
|
||||
canvas.addEventListener("touchend", handleEnd);
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
console.debug("Setup render loop");
|
||||
setupHandler(ref.current);
|
||||
}
|
||||
}, [ref.current]);
|
||||
return <canvas {...props} ref={ref}></canvas>;
|
||||
}
|
@ -6,11 +6,11 @@ import { useInView } from "react-intersection-observer";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { StatePill } from "./state-pill";
|
||||
import { StreamState } from "@/index";
|
||||
import { extractStreamInfo, findTag, getHost } from "@/utils";
|
||||
import { formatSats } from "@/number";
|
||||
import { isContentWarningAccepted } from "./content-warning";
|
||||
import { Tags } from "./tags";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export function VideoTile({
|
||||
ev,
|
||||
|
@ -4,8 +4,7 @@ import { NostrEvent, NoteCollection, RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { LIVE_STREAM } from "@/const";
|
||||
import { StreamState } from "@/index";
|
||||
import { LIVE_STREAM, StreamState } from "@/const";
|
||||
import { findTag, getHost } from "@/utils";
|
||||
import { WEEK } from "@/const";
|
||||
|
||||
|
@ -95,10 +95,6 @@ a {
|
||||
line-height: 20px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
@ -143,13 +139,6 @@ a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea,
|
||||
input[type="datetime-local"],
|
||||
@ -248,8 +237,8 @@ div.paper {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90vw;
|
||||
max-width: 550px;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
|
@ -28,12 +28,6 @@ import { AlertsPage } from "@/pages/alerts";
|
||||
import { StreamSummaryPage } from "@/pages/summary";
|
||||
const DashboardPage = lazy(() => import("./pages/dashboard"));
|
||||
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
Ended = "ended",
|
||||
Planned = "planned",
|
||||
}
|
||||
|
||||
const db = new SnortSystemDb();
|
||||
const System = new NostrSystem({
|
||||
db,
|
||||
|
@ -209,9 +209,6 @@
|
||||
"K7AkdL": {
|
||||
"defaultMessage": "Show"
|
||||
},
|
||||
"KdYELp": {
|
||||
"defaultMessage": "Get stream key"
|
||||
},
|
||||
"KkIL3s": {
|
||||
"defaultMessage": "No, I am under 18"
|
||||
},
|
||||
@ -299,6 +296,9 @@
|
||||
"VA/Z1S": {
|
||||
"defaultMessage": "Hide"
|
||||
},
|
||||
"Vn2WiP": {
|
||||
"defaultMessage": "Get Stream Key"
|
||||
},
|
||||
"W7DNWx": {
|
||||
"defaultMessage": "Stream Forwarding"
|
||||
},
|
||||
@ -399,6 +399,9 @@
|
||||
"j/jueq": {
|
||||
"defaultMessage": "Raiding {name}"
|
||||
},
|
||||
"jJLRgo": {
|
||||
"defaultMessage": "Publish Clip"
|
||||
},
|
||||
"jctiUc": {
|
||||
"defaultMessage": "Highest Viewers"
|
||||
},
|
||||
|
@ -85,16 +85,6 @@ header button {
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1020px) {
|
||||
.hide-on-mobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -110,10 +110,10 @@ export function LayoutPage() {
|
||||
|
||||
return (
|
||||
<Dialog.Root open={showLogin} onOpenChange={setShowLogin}>
|
||||
<button type="button" className="btn btn-border" onClick={handleLogin}>
|
||||
<AsyncButton className="btn btn-border" onClick={handleLogin}>
|
||||
<FormattedMessage defaultMessage="Login" id="AyGauy" />
|
||||
<Icon name="login" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
|
@ -15,11 +15,12 @@ import { MuteButton } from "@/element/mute-button";
|
||||
import { useProfile } from "@/hooks/profile";
|
||||
import useTopZappers from "@/hooks/top-zappers";
|
||||
import { Text } from "@/element/text";
|
||||
import { StreamState } from "@/index";
|
||||
import { findTag } from "@/utils";
|
||||
import { StatePill } from "@/element/state-pill";
|
||||
import { Avatar } from "@/element/avatar";
|
||||
import { ZapperRow } from "@/element/zapper-row";
|
||||
import { StreamState } from "@/const";
|
||||
import AsyncButton from "@/element/async-button";
|
||||
|
||||
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
@ -88,10 +89,10 @@ export function ProfilePage() {
|
||||
aTag={liveEvent ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(liveEvent, "d")}` : undefined}
|
||||
lnurl={zapTarget}
|
||||
button={
|
||||
<button className="btn">
|
||||
<AsyncButton className="btn">
|
||||
<Icon name="zap-filled" className="zap-button-icon" />
|
||||
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
}
|
||||
targetName={profile?.name || link.id}
|
||||
/>
|
||||
|
@ -6,6 +6,7 @@ import Owncast from "@/owncast.png";
|
||||
import Cloudflare from "@/cloudflare.png";
|
||||
import { ConfigureOwncast } from "./owncast";
|
||||
import { ConfigureNostrType } from "./nostr";
|
||||
import AsyncButton from "@/element/async-button";
|
||||
|
||||
export function StreamProvidersPage() {
|
||||
const navigate = useNavigate();
|
||||
@ -37,9 +38,9 @@ export function StreamProvidersPage() {
|
||||
<div className="paper">
|
||||
<h3>{mapName(p)}</h3>
|
||||
{mapLogo(p)}
|
||||
<button className="btn btn-border" onClick={() => navigate(p)}>
|
||||
<AsyncButton className="btn btn-border" onClick={() => navigate(p)}>
|
||||
+ Configure
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,9 +4,9 @@ import { FormattedMessage } from "react-intl";
|
||||
|
||||
import AsyncButton from "@/element/async-button";
|
||||
import { StatePill } from "@/element/state-pill";
|
||||
import { StreamState } from "@/index";
|
||||
import { StreamProviderInfo, StreamProviderStore } from "@/providers";
|
||||
import { NostrStreamProvider } from "@/providers/zsz";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export function ConfigureNostrType() {
|
||||
const [url, setUrl] = useState("");
|
||||
@ -55,14 +55,14 @@ export function ConfigureNostrType() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
<AsyncButton
|
||||
className="btn btn-border"
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new NostrStreamProvider(new URL(url).host, url));
|
||||
navigate("/");
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -3,9 +3,9 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import AsyncButton from "@/element/async-button";
|
||||
import { StatePill } from "@/element/state-pill";
|
||||
import { StreamState } from "@/index";
|
||||
import { StreamProviderInfo, StreamProviderStore } from "@/providers";
|
||||
import { OwncastProvider } from "@/providers/owncast";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export function ConfigureOwncast() {
|
||||
const [url, setUrl] = useState("");
|
||||
@ -55,14 +55,14 @@ export function ConfigureOwncast() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
<AsyncButton
|
||||
className="btn btn-border"
|
||||
onClick={() => {
|
||||
StreamProviderStore.add(new OwncastProvider(url, token));
|
||||
navigate("/");
|
||||
}}>
|
||||
Save
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -8,9 +8,11 @@ import { useLogin } from "@/hooks/login";
|
||||
import Copy from "@/element/copy";
|
||||
import { NostrProviderDialog } from "@/element/nostr-provider-dialog";
|
||||
import { useStreamProvider } from "@/hooks/stream-provider";
|
||||
import { Login, StreamState } from "..";
|
||||
import { Login } from "..";
|
||||
import { StatePill } from "@/element/state-pill";
|
||||
import { NostrStreamProvider } from "@/providers";
|
||||
import { StreamState } from "@/const";
|
||||
import AsyncButton from "@/element/async-button";
|
||||
|
||||
const enum Tab {
|
||||
Account,
|
||||
@ -105,9 +107,9 @@ export function SettingsPage() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
{[Tab.Account].map(t => (
|
||||
<button onClick={() => setTab(t)} className="rounded-xl px-3 py-2 bg-gray-2 hover:bg-gray-1">
|
||||
<AsyncButton onClick={() => setTab(t)} className="rounded-xl px-3 py-2 bg-gray-2 hover:bg-gray-1">
|
||||
{tabName(t)}
|
||||
</button>
|
||||
</AsyncButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-5 bg-gray-2 rounded-3xl flex flex-col gap-3">{tabContent()}</div>
|
||||
|
@ -14,7 +14,6 @@ import { LiveChat } from "@/element/live-chat";
|
||||
import AsyncButton from "@/element/async-button";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useZapGoal } from "@/hooks/goals";
|
||||
import { StreamState } from "@/index";
|
||||
import { SendZapsDialog } from "@/element/send-zap";
|
||||
import { NewStreamDialog } from "@/element/new-stream";
|
||||
import { Tags } from "@/element/tags";
|
||||
@ -27,6 +26,8 @@ import { ContentWarningOverlay, isContentWarningAccepted } from "@/element/conte
|
||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||
import { useStreamLink } from "@/hooks/stream-link";
|
||||
import { FollowButton } from "@/element/follow-button";
|
||||
import { ClipButton } from "@/element/clip-button";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
|
||||
const system = useContext(SnortContext);
|
||||
@ -52,8 +53,8 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv
|
||||
const viewers = Number(participants ?? "0");
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center info">
|
||||
<div className="grow stream-info">
|
||||
<div className="flex gap-2 max-lg:px-2 max-xl:flex-col">
|
||||
<div className="grow flex flex-col gap-2 max-xl:hidden">
|
||||
<h1>{title}</h1>
|
||||
<p>{summary}</p>
|
||||
<div className="tags">
|
||||
@ -77,15 +78,14 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<div className="flex justify-between sm:gap-4 max-sm:gap-2 nowrap max-md:flex-col lg:items-center">
|
||||
<Profile pubkey={host ?? ""} />
|
||||
<div className="flex gap-2">
|
||||
<div className="hide-on-mobile">
|
||||
<FollowButton pubkey={host} />
|
||||
</div>
|
||||
<FollowButton pubkey={host} hideWhenFollowing={true} />
|
||||
{ev && (
|
||||
<>
|
||||
<ShareMenu ev={ev} />
|
||||
<ClipButton ev={ev} />
|
||||
{zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
@ -135,7 +135,11 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
||||
return <ContentWarningOverlay />;
|
||||
}
|
||||
|
||||
const descriptionContent = [title, (summary?.length ?? 0) > 0 ? summary : "Nostr live streaming", ...tags].join(", ");
|
||||
const descriptionContent = [
|
||||
title,
|
||||
(summary?.length ?? 0) > 0 ? summary : "Nostr live streaming",
|
||||
...(tags ?? []),
|
||||
].join(", ");
|
||||
return (
|
||||
<div className="stream-page full-page-height">
|
||||
<Helmet>
|
||||
@ -149,7 +153,12 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
||||
</Helmet>
|
||||
<div className="video-content">
|
||||
<Suspense>
|
||||
<LiveVideoPlayer stream={status === StreamState.Live ? stream : recording} poster={image} status={status} />
|
||||
<LiveVideoPlayer
|
||||
title={title}
|
||||
stream={status === StreamState.Live ? stream : recording}
|
||||
poster={image}
|
||||
status={status}
|
||||
/>
|
||||
</Suspense>
|
||||
<ProfileInfo ev={ev} goal={goal} />
|
||||
<StreamCards host={host} />
|
||||
|
@ -14,6 +14,7 @@ import { Views } from "./widgets/views";
|
||||
import { Music } from "./widgets/music";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import AsyncButton from "@/element/async-button";
|
||||
|
||||
interface ZapAlertConfigurationProps {
|
||||
npub: string;
|
||||
@ -78,6 +79,7 @@ function ZapAlertConfiguration({ npub, baseUrl }: ZapAlertConfigurationProps) {
|
||||
sender: login?.pubkey,
|
||||
amount: 1_000_000,
|
||||
targetEvents: [],
|
||||
created_at: 0,
|
||||
}}
|
||||
/>
|
||||
<div className="text-to-speech-settings">
|
||||
@ -151,9 +153,9 @@ function ZapAlertConfiguration({ npub, baseUrl }: ZapAlertConfigurationProps) {
|
||||
onChange={ev => setTestText(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button disabled={testText.length === 0} className="btn" onClick={testVoice}>
|
||||
<AsyncButton disabled={testText.length === 0} className="btn" onClick={testVoice}>
|
||||
<FormattedMessage defaultMessage="Test voice" id="d5zWyh" />
|
||||
</button>
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { StreamState } from "@/index";
|
||||
import { NostrEvent, SystemInterface } from "@snort/system";
|
||||
import { ExternalStore } from "@snort/shared";
|
||||
import { NostrStreamProvider } from "./zsz";
|
||||
import { ManualProvider } from "./manual";
|
||||
import { OwncastProvider } from "./owncast";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export { NostrStreamProvider } from "./zsz";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StreamState } from "@/index";
|
||||
import { StreamState } from "@/const";
|
||||
import { StreamProvider, StreamProviderInfo, StreamProviders } from "@/providers";
|
||||
|
||||
export class OwncastProvider implements StreamProvider {
|
||||
|
@ -7,9 +7,10 @@ import {
|
||||
StreamProviders,
|
||||
} from ".";
|
||||
import { EventKind, EventPublisher, NostrEvent, SystemInterface } from "@snort/system";
|
||||
import { Login, StreamState } from "@/index";
|
||||
import { Login } from "@/index";
|
||||
import { getPublisher } from "@/login";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import { StreamState } from "@/const";
|
||||
|
||||
export class NostrStreamProvider implements StreamProvider {
|
||||
#publisher?: EventPublisher;
|
||||
@ -91,8 +92,16 @@ export class NostrStreamProvider implements StreamProvider {
|
||||
await this.#getJson("DELETE", `account/forward/${id}`);
|
||||
}
|
||||
|
||||
async createClip(id: string) {
|
||||
return await this.#getJson<{ url: string }>("POST", `clip/${id}`);
|
||||
async prepareClip(id: string) {
|
||||
return await this.#getJson<{ id: string; length: number }>("GET", `clip/${id}`);
|
||||
}
|
||||
|
||||
async createClip(id: string, clipId: string, start: number, length: number) {
|
||||
return await this.#getJson<{ url: string }>("POST", `clip/${id}/${clipId}?start=${start}&length=${length}`);
|
||||
}
|
||||
|
||||
getTempClipUrl(id: string, clipId: string) {
|
||||
return `${this.url}clip/${id}/${clipId}`;
|
||||
}
|
||||
|
||||
async #getJson<T>(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown): Promise<T> {
|
||||
|
@ -69,7 +69,6 @@
|
||||
"Jq3FDz": "Content",
|
||||
"K3r6DQ": "Delete",
|
||||
"K7AkdL": "Show",
|
||||
"KdYELp": "Get stream key",
|
||||
"KkIL3s": "No, I am under 18",
|
||||
"LknBsU": "Stream Key",
|
||||
"MTHO1W": "Start Raid",
|
||||
@ -99,6 +98,7 @@
|
||||
"UJBFYK": "Add Card",
|
||||
"UfSot5": "Past Streams",
|
||||
"VA/Z1S": "Hide",
|
||||
"Vn2WiP": "Get Stream Key",
|
||||
"W7DNWx": "Stream Forwarding",
|
||||
"W9355R": "Unmute",
|
||||
"X2PZ7D": "Create Goal",
|
||||
@ -132,6 +132,7 @@
|
||||
"itPgxd": "Profile",
|
||||
"izWS4J": "Unfollow",
|
||||
"j/jueq": "Raiding {name}",
|
||||
"jJLRgo": "Publish Clip",
|
||||
"jctiUc": "Highest Viewers",
|
||||
"jgOqxt": "Widgets",
|
||||
"jkAQj5": "Stream Ended",
|
||||
|
@ -2,7 +2,6 @@ import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
import type { Tags } from "@/types";
|
||||
import { LIVE_STREAM } from "@/const";
|
||||
import { StreamState } from ".";
|
||||
|
||||
export function toAddress(e: NostrEvent): string {
|
||||
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user