feat: better clips

This commit is contained in:
Kieran 2023-12-13 14:40:52 +00:00
parent 3d21e1ca19
commit 565de1a19e
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
41 changed files with 425 additions and 166 deletions

View File

@ -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 },

View File

@ -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;

View File

@ -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>
)}

View File

@ -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>
</>
);
}

View File

@ -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>

View File

@ -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>
);

View File

@ -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>

View File

@ -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;
}

View File

@ -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()}>

View File

@ -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>
</>
);

View File

@ -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" />

View File

@ -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" />

View File

@ -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() }} />

View File

@ -34,10 +34,6 @@
padding: 12px 16px;
}
.send-zap .btn > span {
justify-content: center;
}
.send-zap .qr {
align-self: center;
}

View File

@ -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>

View File

@ -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={() => {

View File

@ -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>;

View File

@ -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" />

View File

@ -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;

View File

@ -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";

View File

@ -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
View 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>;
}

View File

@ -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,

View File

@ -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";

View File

@ -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;

View File

@ -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,

View File

@ -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"
},

View File

@ -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;

View File

@ -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">

View File

@ -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}
/>

View File

@ -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>
);
}

View File

@ -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>
</>
);

View File

@ -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>
</>
);

View File

@ -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>

View File

@ -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} />

View File

@ -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>
</>
)}
</>

View File

@ -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";

View File

@ -1,4 +1,4 @@
import { StreamState } from "@/index";
import { StreamState } from "@/const";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "@/providers";
export class OwncastProvider implements StreamProvider {

View File

@ -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> {

View File

@ -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",

View File

@ -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) {