Compare commits
10 Commits
cd43bb3cf9
...
f18bd88227
Author | SHA1 | Date | |
---|---|---|---|
f18bd88227 | |||
de88158b3a | |||
|
4bade72dd6 | ||
|
ed8f7a02c1 | ||
|
3c638dd0e3 | ||
f39e0f3d3f | |||
24b9f0ba63 | |||
9cf9199b29 | |||
5540034ade | |||
0a2ba19032 |
@ -8,11 +8,11 @@
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@scure/base": "^1.1.6",
|
||||
"@snort/shared": "^1.0.17",
|
||||
"@snort/system": "^1.5.6",
|
||||
"@snort/system-react": "^1.5.6",
|
||||
"@snort/system": "^1.6.1",
|
||||
"@snort/system-react": "^1.6.1",
|
||||
"@snort/system-wasm": "^1.0.5",
|
||||
"@snort/wallet": "^0.2.1",
|
||||
"@snort/worker-relay": "^1.3.0",
|
||||
"@snort/wallet": "^0.2.4",
|
||||
"@snort/worker-relay": "^1.3.1",
|
||||
"@szhsin/react-menu": "^4.1.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
|
@ -8,8 +8,10 @@ export const GOAL = 9041 as EventKind;
|
||||
export const USER_CARDS = 17_777 as EventKind;
|
||||
export const CARD = 37_777 as EventKind;
|
||||
|
||||
export const VIDEO_KIND = 34_235 as EventKind;
|
||||
export const SHORTS_KIND = 34_236 as EventKind;
|
||||
export const VIDEO_KIND = 21 as EventKind;
|
||||
export const SHORTS_KIND = 22 as EventKind;
|
||||
export const OLD_VIDEO_KIND = 34_235 as EventKind;
|
||||
export const OLD_SHORTS_KIND = 34_236 as EventKind;
|
||||
|
||||
export const MINUTE = 60;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
|
@ -5,13 +5,15 @@ import { Goal } from "./goal";
|
||||
import { Note } from "./note";
|
||||
import { EmojiPack } from "./emoji-pack";
|
||||
import { BadgeInfo } from "./badge";
|
||||
import { GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const";
|
||||
import { GOAL, LIVE_STREAM_CLIP, OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, StreamState, VIDEO_KIND } from "@/const";
|
||||
import { useEventFeed } from "@snort/system-react";
|
||||
import LiveStreamClip from "./stream/clip";
|
||||
import { ExternalLink } from "./external-link";
|
||||
import { extractStreamInfo } from "@/utils";
|
||||
import LiveVideoPlayer from "./stream/live-video-player";
|
||||
import { HTMLProps } from "react";
|
||||
import { HTMLProps, ReactNode } from "react";
|
||||
import { ShortPage } from "@/pages/short";
|
||||
import { VideoPage } from "@/pages/video";
|
||||
|
||||
interface EventProps {
|
||||
link: NostrLink;
|
||||
@ -31,36 +33,49 @@ export function EventIcon({ kind }: { kind?: EventKind }) {
|
||||
}
|
||||
|
||||
export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
function modalPage(inner: ReactNode) {
|
||||
return <div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">{inner}</div>;
|
||||
}
|
||||
|
||||
switch (ev.kind) {
|
||||
case GOAL: {
|
||||
return <Goal ev={ev} />;
|
||||
return modalPage(<Goal ev={ev} />);
|
||||
}
|
||||
case EventKind.EmojiSet: {
|
||||
return <EmojiPack ev={ev} />;
|
||||
return modalPage(<EmojiPack ev={ev} />);
|
||||
}
|
||||
case EventKind.Badge: {
|
||||
return <BadgeInfo ev={ev} />;
|
||||
return modalPage(<BadgeInfo ev={ev} />);
|
||||
}
|
||||
case EventKind.TextNote: {
|
||||
return <Note ev={ev} />;
|
||||
return modalPage(<Note ev={ev} />);
|
||||
}
|
||||
case LIVE_STREAM_CLIP: {
|
||||
return <LiveStreamClip ev={ev} />;
|
||||
return modalPage(<LiveStreamClip ev={ev} />);
|
||||
}
|
||||
case OLD_SHORTS_KIND:
|
||||
case SHORTS_KIND: {
|
||||
return <ShortPage link={link} evPreload={ev} />;
|
||||
}
|
||||
case OLD_VIDEO_KIND:
|
||||
case VIDEO_KIND: {
|
||||
return <VideoPage link={link} evPreload={ev} />;
|
||||
}
|
||||
case EventKind.LiveEvent: {
|
||||
const info = extractStreamInfo(ev);
|
||||
return (
|
||||
return modalPage(
|
||||
<LiveVideoPlayer
|
||||
link={link}
|
||||
title={info.title}
|
||||
status={info.status}
|
||||
stream={info.status === StreamState.Live ? info.stream : info.recording}
|
||||
poster={info.image}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
return <ExternalLink href={`https://snort.social/${link.encode()}`}>{link.encode()}</ExternalLink>;
|
||||
return modalPage(<ExternalLink href={`https://snort.social/${link.encode()}`}>{link.encode()}</ExternalLink>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish, onTa
|
||||
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
|
||||
const [isFiat, setIsFiat] = useState(false);
|
||||
const [svc, setSvc] = useState<LNURLLike>();
|
||||
const [customAmount, setCustomAmount] = useState(false);
|
||||
const [amount, setAmount] = useState(satsAmounts[0]);
|
||||
const [comment, setComment] = useState("");
|
||||
const [invoice, setInvoice] = useState("");
|
||||
@ -158,12 +159,17 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish, onTa
|
||||
)}
|
||||
</small>
|
||||
<div className="grid grid-cols-5 gap-2 text-center">
|
||||
{(isFiat ? usdAmounts : satsAmounts).map(a => (
|
||||
<Pill key={a} selected={a === amount} onClick={() => setAmount(a)}>
|
||||
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
|
||||
</Pill>
|
||||
))}
|
||||
{!customAmount &&
|
||||
(isFiat ? usdAmounts : satsAmounts).map(a => (
|
||||
<Pill key={a} selected={a === amount} onClick={() => setAmount(a)}>
|
||||
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
|
||||
</Pill>
|
||||
))}
|
||||
<Pill onClick={() => setCustomAmount(s => !s)} selected={customAmount}>
|
||||
<FormattedMessage defaultMessage="Custom" />
|
||||
</Pill>
|
||||
</div>
|
||||
{customAmount && <input type="number" value={amount} onChange={e => setAmount(e.target.valueAsNumber)} />}
|
||||
</div>
|
||||
{svc && (svc.maxCommentLength > 0 || svc.canZap) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
@ -145,7 +145,8 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const startsDate = new Date(parseInt(start ?? "0") * 1000);
|
||||
const startsTimestamp = Number(start ?? (new Date().getTime() / 1000));
|
||||
const startsDate = new Date(startsTimestamp * 1000);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -203,9 +204,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||
<StreamInput label={<FormattedMessage defaultMessage="Start Time" />}>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={`${startsDate.getFullYear().toString().padStart(4, "0")}-${startsDate.getMonth().toString().padStart(2, "0")}-${startsDate.getDate().toString().padStart(2, "0")}T${startsDate.getHours().toString().padStart(2, "0")}:${startsDate.getMinutes().toString().padStart(2, "0")}`}
|
||||
value={`${startsDate.getFullYear().toString().padStart(4, "0")}-${(startsDate.getMonth() + 1).toString().padStart(2, "0")}-${startsDate.getDate().toString().padStart(2, "0")}T${startsDate.getHours().toString().padStart(2, "0")}:${startsDate.getMinutes().toString().padStart(2, "0")}`}
|
||||
onChange={e => {
|
||||
setStart((e.target.valueAsNumber / 1000).toString());
|
||||
setStart((new Date(e.target.value) / 1000).toString());
|
||||
}}
|
||||
/>
|
||||
</StreamInput>
|
||||
|
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { CSSProperties, HTMLProps } from "react";
|
||||
import { CSSProperties, HTMLProps, Suspense, lazy } from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
MediaControlBar,
|
||||
@ -20,8 +20,8 @@ import {
|
||||
} from "media-chrome/react";
|
||||
import "hls-video-element";
|
||||
import { StreamState } from "@/const";
|
||||
import Nip94Player from "./n94-player";
|
||||
import { NostrLink } from "@snort/system";
|
||||
const Nip94Player = lazy(() => import("./n94-player"));
|
||||
|
||||
type VideoPlayerProps = {
|
||||
title?: string;
|
||||
@ -35,12 +35,15 @@ type VideoPlayerProps = {
|
||||
export default function LiveVideoPlayer({ title, stream, status, poster, link, ...props }: VideoPlayerProps) {
|
||||
function innerPlayer() {
|
||||
if (stream === "nip94") {
|
||||
return <Nip94Player link={link} />;
|
||||
}
|
||||
{
|
||||
return (
|
||||
<Suspense>
|
||||
<Nip94Player link={link} />
|
||||
</Suspense>
|
||||
);
|
||||
} else {
|
||||
/* @ts-ignore Web Componenet */
|
||||
return <hls-video {...props} slot="media" src={stream} playsInline={true} autoPlay={true} />;
|
||||
}
|
||||
return <hls-video {...props} slot="media" src={stream} playsInline={true} autoPlay={true} />;
|
||||
}
|
||||
return (
|
||||
<MediaController
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { NostrLink, NostrPrefix, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LIVE_STREAM } from "@/const";
|
||||
import { getHost } from "@/utils";
|
||||
|
||||
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
|
||||
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`current-event:${link.id}`);
|
||||
b.withOptions({
|
||||
@ -16,14 +13,8 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
|
||||
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
|
||||
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]);
|
||||
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]);
|
||||
} else if (link.type === NostrPrefix.Address) {
|
||||
const f = b.withFilter().tag("d", [link.id]);
|
||||
if (link.author) {
|
||||
f.authors([link.author]);
|
||||
}
|
||||
if (link.kind) {
|
||||
f.kinds([link.kind]);
|
||||
}
|
||||
} else {
|
||||
b.withFilter().link(link);
|
||||
}
|
||||
return b;
|
||||
}, [link.id, leaveOpen]);
|
||||
@ -31,9 +22,7 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
|
||||
const q = useRequestBuilder(sub);
|
||||
|
||||
return useMemo(() => {
|
||||
const hosting = [...q, ...(evPreload ? [evPreload] : [])]
|
||||
.filter(a => getHost(a) === author || a.pubkey === author)
|
||||
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
|
||||
const hosting = [...q, ...(evPreload ? [evPreload] : [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
|
||||
return hosting.at(0);
|
||||
}, [q]);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { MediaPayload, VideoInfo } from "@/service/video/info";
|
||||
import { findTag } from "@/utils";
|
||||
import { NostrEvent, TaggedNostrEvent } from "@snort/system";
|
||||
@ -58,7 +58,7 @@ export function useDeadLink(ev: TaggedNostrEvent | NostrEvent) {
|
||||
|
||||
useEffect(() => {
|
||||
const links =
|
||||
ev.kind === VIDEO_KIND || ev.kind === SHORTS_KIND
|
||||
ev.kind === VIDEO_KIND || ev.kind === SHORTS_KIND || ev.kind == OLD_SHORTS_KIND || ev.kind == OLD_VIDEO_KIND
|
||||
? VideoInfo.parse(ev)?.sources()
|
||||
: [
|
||||
{
|
||||
|
@ -4,12 +4,7 @@ import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
|
||||
import { Nip96Server } from "@/service/upload/nip96";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const DefaultMediaServers = [
|
||||
//"https://media.zap.stream",
|
||||
new UnknownTag(["server", "https://nostr.build/"]),
|
||||
new UnknownTag(["server", "https://nostrcheck.me/"]),
|
||||
new UnknownTag(["server", "https://files.v0l.io/"]),
|
||||
];
|
||||
export const DefaultMediaServers = [new UnknownTag(["server", "https://nostr.download/"])];
|
||||
|
||||
export function useMediaServerList() {
|
||||
const login = useLogin();
|
||||
|
@ -221,6 +221,9 @@
|
||||
"BD0vyn": {
|
||||
"defaultMessage": "{name} created a clip"
|
||||
},
|
||||
"BHNL+v": {
|
||||
"defaultMessage": "Raw Data:"
|
||||
},
|
||||
"Bd1yEX": {
|
||||
"defaultMessage": "New Stream Goal"
|
||||
},
|
||||
@ -533,6 +536,9 @@
|
||||
"SC2nJT": {
|
||||
"defaultMessage": "Audio Codec"
|
||||
},
|
||||
"Sjo1P4": {
|
||||
"defaultMessage": "Custom"
|
||||
},
|
||||
"TDUfVk": {
|
||||
"defaultMessage": "Started"
|
||||
},
|
||||
@ -638,6 +644,9 @@
|
||||
"ZcgtZo": {
|
||||
"defaultMessage": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice."
|
||||
},
|
||||
"Zfr//4": {
|
||||
"defaultMessage": "No duration provided, please try another upload server."
|
||||
},
|
||||
"ZmqxZs": {
|
||||
"defaultMessage": "You can change this later"
|
||||
},
|
||||
@ -702,6 +711,9 @@
|
||||
"dkUMIH": {
|
||||
"defaultMessage": "Clip by {name}"
|
||||
},
|
||||
"dqGkI+": {
|
||||
"defaultMessage": "Video durations vary too much, are you sure each variant is the same video?"
|
||||
},
|
||||
"e011kf": {
|
||||
"defaultMessage": "FAQ",
|
||||
"description": "Title: FAQ page"
|
||||
@ -730,6 +742,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} messages"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go."
|
||||
},
|
||||
@ -939,6 +954,9 @@
|
||||
"vP4dFa": {
|
||||
"defaultMessage": "Visit {link} to get some sweet zap.stream merch!"
|
||||
},
|
||||
"vaZKTn": {
|
||||
"defaultMessage": "Add more content"
|
||||
},
|
||||
"vrTOHJ": {
|
||||
"defaultMessage": "{amount} sats"
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import { NostrLink, parseNostrLink } from "@snort/system";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
import { LiveChat } from "@/element/chat/live-chat";
|
||||
@ -13,19 +13,20 @@ export function ChatPopout() {
|
||||
const ev = useCurrentStreamFeed(link, true);
|
||||
const goal = useZapGoal(findTag(ev, "goal"));
|
||||
|
||||
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
|
||||
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
|
||||
return (
|
||||
<div className="h-[calc(100vh-1rem)] w-screen px-2 my-2">
|
||||
<LiveChat
|
||||
ev={ev}
|
||||
link={lnk}
|
||||
canWrite={chat}
|
||||
showScrollbar={false}
|
||||
goal={goal}
|
||||
className="h-inherit"
|
||||
autoRaid={false}
|
||||
/>
|
||||
{ev && (
|
||||
<LiveChat
|
||||
ev={ev}
|
||||
link={NostrLink.fromEvent(ev)}
|
||||
canWrite={chat}
|
||||
showScrollbar={false}
|
||||
goal={goal}
|
||||
className="h-inherit"
|
||||
autoRaid={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -16,12 +16,12 @@ import { DashboardCard } from "./card";
|
||||
import { NewStreamDialog } from "@/element/new-stream";
|
||||
import { DashboardSettingsButton } from "./button-settings";
|
||||
import DashboardIntro from "./intro";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import StreamKey from "@/element/provider/nostr/stream-key";
|
||||
import { DefaultProvider, NostrStreamProvider, StreamProviderInfo } from "@/providers";
|
||||
import { ExternalLink } from "@/element/external-link";
|
||||
import BalanceTimeEstimate from "@/element/balance-time-estimate";
|
||||
import { Layer1Button, WarningButton } from "@/element/buttons";
|
||||
import { Layer1Button, Layer2Button, WarningButton } from "@/element/buttons";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import AccountTopup from "@/element/provider/nostr/topup";
|
||||
import classNames from "classnames";
|
||||
@ -30,15 +30,19 @@ import { unixNow } from "@snort/shared";
|
||||
import { Icon } from "@/element/icon";
|
||||
import ForwardingModal from "./forwarding";
|
||||
import BalanceHistoryModal from "./balance-history";
|
||||
import Modal from "@/element/modal";
|
||||
import { AcceptTos } from "./tos";
|
||||
const StreamSummary = lazy(() => import("@/element/summary-chart"));
|
||||
|
||||
export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
export default function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
const navigate = useNavigate();
|
||||
const streamEvent = useCurrentStreamFeed(link, true);
|
||||
const location = useLocation();
|
||||
const login = useLogin();
|
||||
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
|
||||
const { stream, status, image, participants, service } = extractStreamInfo(streamEvent);
|
||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||
const [tos, setTos] = useState(info?.tosAccepted ?? false);
|
||||
const isMyManual = streamEvent?.pubkey === login?.pubkey;
|
||||
const system = useContext(SnortContext);
|
||||
const [recording, setRecording] = useState(Boolean(localStorage.getItem("default-recording") ?? "true"));
|
||||
@ -110,7 +114,7 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
<div
|
||||
className={classNames("grid gap-2 h-[calc(100dvh-52px)] w-full", {
|
||||
"grid-cols-3": status === StreamState.Live,
|
||||
"grid-cols-[20%_80%]": status === StreamState.Ended,
|
||||
"grid-cols-[20%_80%]": status === StreamState.Ended || status === undefined,
|
||||
"grid-cols-[40%_60%]": status === StreamState.Planned,
|
||||
})}>
|
||||
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
|
||||
@ -126,7 +130,14 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
</div>
|
||||
{streamLink && status === StreamState.Live && !isMyManual && (
|
||||
<>
|
||||
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
|
||||
<LiveVideoPlayer
|
||||
stream={stream}
|
||||
link={streamLink}
|
||||
status={status}
|
||||
poster={image}
|
||||
muted={true}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<DashboardStatsCard
|
||||
name={<FormattedMessage defaultMessage="Stream Time" />}
|
||||
@ -170,7 +181,14 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
)}
|
||||
{streamLink && isMyManual && (status === StreamState.Live || status === StreamState.Planned) && (
|
||||
<>
|
||||
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
|
||||
<LiveVideoPlayer
|
||||
link={streamLink}
|
||||
stream={stream}
|
||||
status={status}
|
||||
poster={image}
|
||||
muted={true}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="grid gap-2 grid-cols-3">
|
||||
<DashboardRaidButton link={streamLink} />
|
||||
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
|
||||
@ -283,6 +301,29 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
|
||||
</>
|
||||
)}
|
||||
{streamLink && status === StreamState.Planned && <DashboardCard className="overflow-y-auto"></DashboardCard>}
|
||||
{info && !info.tosAccepted && (
|
||||
<Modal id="tos-dashboard">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2>Please accept TOS before continuing</h2>
|
||||
<AcceptTos provider={info?.name} tosLink={info?.tosLink} tos={tos} setTos={setTos} />
|
||||
<div className="flex items-center justify-between">
|
||||
<Layer2Button
|
||||
disabled={!tos}
|
||||
onClick={async () => {
|
||||
if (tos) {
|
||||
await provider.acceptTos();
|
||||
provider.info().then(setInfo);
|
||||
}
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Save" />
|
||||
</Layer2Button>
|
||||
<WarningButton onClick={() => navigate("/")}>
|
||||
<FormattedMessage defaultMessage="No Thanks!" />
|
||||
</WarningButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { NostrLink, NostrPrefix, parseNostrLink } from "@snort/system";
|
||||
import { DashboardForLink } from "./dashboard";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const DashboardForLink = lazy(() => import("./dashboard"));
|
||||
|
||||
export default function DashboardPage() {
|
||||
const login = useLogin();
|
||||
const { id } = useParams();
|
||||
if (!login) return;
|
||||
const link = id ? parseNostrLink(id) : new NostrLink(NostrPrefix.PublicKey, login.pubkey);
|
||||
|
||||
return <DashboardForLink link={link} />;
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardForLink link={link} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ZapGlow from "../zap-glow";
|
||||
import { AcceptTos } from "../tos";
|
||||
|
||||
export default function DashboardIntro() {
|
||||
const navigate = useNavigate();
|
||||
@ -16,7 +17,7 @@ export default function DashboardIntro() {
|
||||
const exampleHours = 4;
|
||||
|
||||
const defaultEndpoint = useMemo(() => {
|
||||
return info?.endpoints.find(a => a.name == "Best") ?? info?.endpoints[0];
|
||||
return info?.endpoints?.find(a => a.name == "Best") ?? info?.endpoints?.at(0);
|
||||
}, [info]);
|
||||
const rate = useRates("BTCUSD");
|
||||
const exampleCost = rate.ask * (exampleHours * (defaultEndpoint?.rate ?? 0) * 60) * 1e-8;
|
||||
@ -69,31 +70,7 @@ export default function DashboardIntro() {
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
{!info?.tosAccepted && (
|
||||
<div>
|
||||
<div className="flex gap-2 cursor-pointer select-none" onClick={() => setTos(v => !v)}>
|
||||
<input type="checkbox" checked={tos} onChange={e => setTos(e.target.checked)} />
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="I have read and agree with {provider}'s {terms}."
|
||||
values={{
|
||||
provider: info?.name,
|
||||
terms: (
|
||||
<span
|
||||
className="text-primary"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
window.open(info?.tosLink, "popup", "width=400,height=800");
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="terms and conditions" />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!info?.tosAccepted && <AcceptTos provider={info?.name} tosLink={info?.tosLink} tos={tos} setTos={setTos} />}
|
||||
<DefaultButton
|
||||
disabled={!tos}
|
||||
onClick={async () => {
|
||||
|
39
src/pages/dashboard/tos.tsx
Normal file
39
src/pages/dashboard/tos.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function AcceptTos({
|
||||
provider,
|
||||
tosLink,
|
||||
tos,
|
||||
setTos,
|
||||
}: {
|
||||
provider?: string;
|
||||
tosLink?: string;
|
||||
tos: boolean;
|
||||
setTos: (f: (r: boolean) => boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 cursor-pointer select-none" onClick={() => setTos(v => !v)}>
|
||||
<input type="checkbox" checked={tos} onChange={e => setTos(() => e.target.checked)} />
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="I have read and agree with {provider}'s {terms}."
|
||||
values={{
|
||||
provider,
|
||||
terms: (
|
||||
<span
|
||||
className="text-primary"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
window.open(tosLink, "popup", "width=400,height=800");
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="terms and conditions" />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { LIVE_STREAM, SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { LIVE_STREAM, OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { useStreamLink } from "@/hooks/stream-link";
|
||||
import { getEventFromLocationState } from "@/utils";
|
||||
import { NostrPrefix } from "@snort/system";
|
||||
@ -20,20 +20,16 @@ export function LinkHandler() {
|
||||
if (!link) return;
|
||||
|
||||
if (link.type === NostrPrefix.Event) {
|
||||
return (
|
||||
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
|
||||
<NostrEventElement link={link} />
|
||||
</div>
|
||||
);
|
||||
return <NostrEventElement link={link} />;
|
||||
} else if (link.kind === LIVE_STREAM || link.type === NostrPrefix.PublicKey) {
|
||||
return (
|
||||
<div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}>
|
||||
<StreamPage link={link} evPreload={evPreload} />
|
||||
</div>
|
||||
);
|
||||
} else if (link.kind === VIDEO_KIND) {
|
||||
} else if (link.kind === VIDEO_KIND || link.kind === OLD_VIDEO_KIND) {
|
||||
return <VideoPage link={link} evPreload={evPreload} />;
|
||||
} else if (link.kind === SHORTS_KIND) {
|
||||
} else if (link.kind === SHORTS_KIND || link.kind === OLD_SHORTS_KIND) {
|
||||
return <ShortPage link={link} evPreload={evPreload} />;
|
||||
} else {
|
||||
return (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SHORTS_KIND } from "@/const";
|
||||
import { OLD_SHORTS_KIND, SHORTS_KIND } from "@/const";
|
||||
import VideoGrid from "@/element/video-grid";
|
||||
import { VideoTile } from "@/element/video/video-tile";
|
||||
import { findTag } from "@/utils";
|
||||
@ -8,13 +8,12 @@ import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function ShortsPage() {
|
||||
const rb = new RequestBuilder("shorts");
|
||||
rb.withFilter().kinds([SHORTS_KIND]);
|
||||
rb.withFilter().kinds([SHORTS_KIND, OLD_SHORTS_KIND]);
|
||||
|
||||
const videos = useRequestBuilder(rb);
|
||||
|
||||
const sorted = videos.sort((a, b) => {
|
||||
const pubA = findTag(a, "published_at");
|
||||
const pubB = findTag(b, "published_at");
|
||||
const pubA = findTag(a, "published_at") ?? a.created_at;
|
||||
const pubB = findTag(b, "published_at") ?? b.created_at;
|
||||
return Number(pubA) > Number(pubB) ? -1 : 1;
|
||||
});
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { DefaultButton, IconButton, Layer3Button, PrimaryButton, WarningButton } from "@/element/buttons";
|
||||
import { Icon } from "@/element/icon";
|
||||
import Modal from "@/element/modal";
|
||||
@ -9,11 +9,11 @@ import { ServerList } from "@/element/upload/server-list";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useMediaServerList } from "@/hooks/media-servers";
|
||||
import { Nip94Tags, UploadResult, nip94TagsToIMeta } from "@/service/upload";
|
||||
import { Nip94Tags, UploadResult, nip94TagsToIMeta, readNip94Tags } from "@/service/upload";
|
||||
import { Nip96Server } from "@/service/upload/nip96";
|
||||
import { openFile } from "@/utils";
|
||||
import { ExternalStore, removeUndefined, unixNow, unwrap } from "@snort/shared";
|
||||
import { EventPublisher, NostrLink } from "@snort/system";
|
||||
import { ExternalStore, removeUndefined, unwrap } from "@snort/shared";
|
||||
import { EventBuilder, EventPublisher, NostrEvent, NostrLink } from "@snort/system";
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
@ -69,6 +69,23 @@ class UploadManager extends ExternalStore<Array<UploadStatus>> {
|
||||
}
|
||||
}
|
||||
|
||||
addUpload(server: string, file: NostrEvent, meta: Nip94Tags, type: UploadStatus["type"]) {
|
||||
const name = file.content ?? meta.summary ?? meta.alt ?? "";
|
||||
const uploadKey = `${name}:${server}:${type}`;
|
||||
this.#uploads.set(uploadKey, {
|
||||
type,
|
||||
name,
|
||||
size: meta.size ?? 0,
|
||||
server,
|
||||
result: {
|
||||
url: meta.url,
|
||||
header: file,
|
||||
metadata: meta,
|
||||
},
|
||||
});
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
async uploadTo(server: string, file: File, pub: EventPublisher, type: UploadStatus["type"]) {
|
||||
let uploader = this.#uploaders.get(server);
|
||||
if (!uploader) {
|
||||
@ -130,6 +147,27 @@ class UploadManager extends ExternalStore<Array<UploadStatus>> {
|
||||
return resGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the [min, max] duration from all variants
|
||||
*/
|
||||
duration() {
|
||||
const uploads = this.snapshot();
|
||||
return uploads.reduce(
|
||||
(acc, v) => {
|
||||
if (v.result?.metadata?.duration) {
|
||||
if (acc[1] < v.result.metadata.duration) {
|
||||
acc[1] = v.result.metadata.duration;
|
||||
}
|
||||
if (acc[0] > v.result.metadata.duration) {
|
||||
acc[0] = v.result.metadata.duration;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[1_000_000, 0],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `imeta` tag for this upload
|
||||
*/
|
||||
@ -187,21 +225,24 @@ export function UploadPage() {
|
||||
return error.length == 0 && uploads.length > 0 && uploads.every(a => a.result !== undefined);
|
||||
}
|
||||
|
||||
function makeEvent() {
|
||||
const duration = manager.duration();
|
||||
const eb = new EventBuilder()
|
||||
.pubKey(login?.pubkey ?? "00".repeat(31))
|
||||
.kind(duration[1] <= 60 ? SHORTS_KIND : VIDEO_KIND)
|
||||
.tag(["title", title])
|
||||
.content(summary);
|
||||
|
||||
const imeta = manager.makeIMeta();
|
||||
imeta.forEach(a => eb.tag(a));
|
||||
|
||||
return eb;
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const pub = login?.publisher();
|
||||
if (!pub) return;
|
||||
const ev = await pub.generic(eb => {
|
||||
eb.kind(VIDEO_KIND);
|
||||
eb.tag(["d", manager.id]);
|
||||
eb.tag(["title", title]);
|
||||
eb.tag(["published_at", unixNow().toString()]);
|
||||
eb.content(summary);
|
||||
|
||||
const imeta = manager.makeIMeta();
|
||||
imeta.forEach(a => eb.tag(a));
|
||||
|
||||
return eb;
|
||||
});
|
||||
const ev = await makeEvent().buildAndSign(pub.signer);
|
||||
console.debug(ev);
|
||||
await system.BroadcastEvent(ev);
|
||||
navigate(`/${NostrLink.fromEvent(ev).encode()}`);
|
||||
@ -229,7 +270,16 @@ export function UploadPage() {
|
||||
const data = await rsp.blob();
|
||||
const pub = login?.publisher();
|
||||
if (pub) {
|
||||
servers.servers.forEach(b => manager.uploadTo(b, new File([data], "thumb.jpg"), pub, "thumb"));
|
||||
servers.servers.forEach(b =>
|
||||
manager.uploadTo(
|
||||
b,
|
||||
new File([data], "thumb.jpg", {
|
||||
type: "image/jpeg",
|
||||
}),
|
||||
pub,
|
||||
"thumb",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -291,6 +341,17 @@ export function UploadPage() {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const d = manager.duration();
|
||||
if (d[0] === 0 || d[1] === 0) {
|
||||
err.push(formatMessage({ defaultMessage: "No duration provided, please try another upload server." }));
|
||||
}
|
||||
if (Math.abs(d[0] - d[1]) >= 0.5) {
|
||||
err.push(
|
||||
formatMessage({
|
||||
defaultMessage: "Video durations vary too much, are you sure each variant is the same video?",
|
||||
}),
|
||||
);
|
||||
}
|
||||
setError(err);
|
||||
}
|
||||
|
||||
@ -312,6 +373,7 @@ export function UploadPage() {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-xl:w-full xl:w-[1200px] xl:mx-auto grid gap-6 xl:grid-cols-[auto_350px] max-xl:px-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
@ -359,8 +421,11 @@ export function UploadPage() {
|
||||
))}
|
||||
</div>
|
||||
{videos > 0 && (
|
||||
<div onClick={() => uploadFile()} className="cursor-pointer">
|
||||
{uploadButton()}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xl">
|
||||
<FormattedMessage defaultMessage="Add more content" />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">{uploadButton()}</div>
|
||||
</div>
|
||||
)}
|
||||
{uploads.length > 0 && (
|
||||
@ -377,7 +442,7 @@ export function UploadPage() {
|
||||
<FormattedMessage defaultMessage="Thumbnail" />
|
||||
</div>
|
||||
<div className="border border-layer-3 border-dashed border-2 rounded-xl aspect-video overflow-hidden">
|
||||
{thumb && <img src={proxy(thumb)} className="w-full h-full" />}
|
||||
{thumb && <img src={proxy(thumb)} className="w-full h-full object-contain" />}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<DefaultButton onClick={() => uploadThumb()}>
|
||||
@ -405,9 +470,13 @@ export function UploadPage() {
|
||||
</WarningButton>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-xs font-mono overflow-wrap text-pretty">
|
||||
{JSON.stringify(manager.makeIMeta(), undefined, 2)}
|
||||
</pre>
|
||||
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Raw Data:" />
|
||||
<pre className="text-xs font-mono overflow-wrap text-pretty">
|
||||
{JSON.stringify(makeEvent().build(), undefined, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{editServers && (
|
||||
<Modal id="server-list" onClose={() => setEditServers(false)}>
|
||||
<ServerList />
|
||||
@ -416,7 +485,19 @@ export function UploadPage() {
|
||||
{mediaPicker && (
|
||||
<Modal id="media-picker" onClose={() => setMediaPicker(false)} largeModal={true}>
|
||||
<MediaServerFileList
|
||||
onPicked={() => {
|
||||
onPicked={files => {
|
||||
files.forEach(f => {
|
||||
const meta = readNip94Tags(f.tags);
|
||||
if (meta.url) {
|
||||
const url = new URL(meta.url);
|
||||
manager.addUpload(
|
||||
`${url.protocol}//${url.host}/`,
|
||||
f,
|
||||
meta,
|
||||
meta.mimeType?.startsWith("image/") ?? false ? "thumb" : "video",
|
||||
);
|
||||
}
|
||||
});
|
||||
setMediaPicker(false);
|
||||
}}
|
||||
/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { OLD_VIDEO_KIND, VIDEO_KIND } from "@/const";
|
||||
import VideoGrid from "@/element/video-grid";
|
||||
import { findTag, getHost } from "@/utils";
|
||||
import { NostrLink, RequestBuilder } from "@snort/system";
|
||||
@ -11,7 +11,7 @@ export function VideosPage() {
|
||||
const login = useLogin();
|
||||
|
||||
const rb = new RequestBuilder("videos");
|
||||
rb.withFilter().kinds([VIDEO_KIND]);
|
||||
rb.withFilter().kinds([VIDEO_KIND, OLD_VIDEO_KIND]);
|
||||
|
||||
const videos = useRequestBuilder(rb);
|
||||
|
||||
@ -22,8 +22,8 @@ export function VideosPage() {
|
||||
return (login?.state?.muted.length ?? 0) === 0 || !login?.state?.muted.some(a => a.equals(link));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pubA = findTag(a, "published_at");
|
||||
const pubB = findTag(b, "published_at");
|
||||
const pubA = findTag(a, "published_at") ?? a.created_at;
|
||||
const pubB = findTag(b, "published_at") ?? b.created_at;
|
||||
return Number(pubA) > Number(pubB) ? -1 : 1;
|
||||
});
|
||||
|
||||
|
@ -16,6 +16,8 @@ export interface Nip94Tags {
|
||||
summary?: string;
|
||||
alt?: string;
|
||||
fallback?: Array<string>;
|
||||
duration?: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
@ -103,6 +105,14 @@ export function readNip94Tags(tags: Array<Array<string>>) {
|
||||
res.fallback.push(v);
|
||||
break;
|
||||
}
|
||||
case "duration": {
|
||||
res.duration = Number(v);
|
||||
break;
|
||||
}
|
||||
case "bitrate": {
|
||||
res.bitrate = Number(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@ -126,6 +136,8 @@ export function nip94TagsToIMeta(meta: Nip94Tags) {
|
||||
ifPush("thumb", meta.thumb);
|
||||
ifPush("summary", meta.summary);
|
||||
ifPush("alt", meta.alt);
|
||||
ifPush("duration", meta.duration);
|
||||
ifPush("bitrate", meta.bitrate);
|
||||
if (meta.image) {
|
||||
meta.image.forEach(a => ifPush("image", a));
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ export class Nip96Server {
|
||||
const fd = new FormData();
|
||||
fd.append("size", file.size.toString());
|
||||
fd.append("caption", filename);
|
||||
fd.append("media_type", file.type);
|
||||
fd.append("content_type", file.type);
|
||||
fd.append("file", file);
|
||||
|
||||
const rsp = await this.#req("", "POST", fd);
|
||||
|
@ -2,7 +2,6 @@ import { NostrEvent } from "@snort/system";
|
||||
import { GameInfo } from "../game-database";
|
||||
import { Nip94Tags, readNip94Tags, readNip94TagsFromIMeta } from "../upload";
|
||||
import { getHost, sortStreamTags, extractGameTag, findTag } from "@/utils";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export interface MediaPayload {
|
||||
url: string;
|
||||
@ -19,9 +18,12 @@ export class VideoInfo {
|
||||
goal?: string;
|
||||
gameId?: string;
|
||||
gameInfo?: GameInfo;
|
||||
duration?: number;
|
||||
publishedAt?: number;
|
||||
|
||||
get duration() {
|
||||
return this.media.find(m => m.duration)?.duration;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly host: string,
|
||||
readonly id: string,
|
||||
@ -31,28 +33,13 @@ export class VideoInfo {
|
||||
|
||||
static parse(ev: NostrEvent) {
|
||||
const { regularTags, prefixedTags } = sortStreamTags(ev.tags);
|
||||
const ret = new VideoInfo(getHost(ev), unwrap(findTag(ev, "d")), regularTags, VideoInfo.#parseMediaTags(ev.tags));
|
||||
const ret = new VideoInfo(getHost(ev), findTag(ev, "d") ?? ev.id, regularTags, VideoInfo.#parseMediaTags(ev.tags));
|
||||
|
||||
const matchInto = <K extends keyof VideoInfo>(
|
||||
tag: Array<string>,
|
||||
key: string,
|
||||
into: K,
|
||||
fn?: (v: string) => never,
|
||||
) => {
|
||||
if (tag[0] === key) {
|
||||
ret[into] = fn ? fn(tag[1]) : (tag[1] as never);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of ev.tags) {
|
||||
matchInto(t, "d", "id");
|
||||
matchInto(t, "title", "title");
|
||||
matchInto(t, "summary", "summary");
|
||||
matchInto(t, "content-warning", "contentWarning");
|
||||
matchInto(t, "goal", "goal");
|
||||
matchInto(t, "duration", "duration");
|
||||
matchInto(t, "published_at", "publishedAt");
|
||||
}
|
||||
ret.title = findTag(ev, "title");
|
||||
ret.summary = findTag(ev, "summary") ?? ev.content;
|
||||
ret.contentWarning = findTag(ev, "content-warning");
|
||||
ret.goal = findTag(ev, "goal");
|
||||
ret.publishedAt = Number(findTag(ev, "published_at") ?? ev.created_at);
|
||||
|
||||
const { gameInfo, gameId } = extractGameTag(prefixedTags);
|
||||
ret.gameId = gameId;
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} الرسائل"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "إذا كنت تستخدم استضافة zap.stream الداخلية الخاصة بنا (الأرخص والأسهل)، انسخ عنوان URL للبث ومفتاح البث إلى إعدادات OBS الخاصة بك وستكون جاهزًا للعمل."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} съобщения"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Ако използвате нашия собствен хостинг zap.stream (най-евтиният и най-лесният), копирайте URL адреса на потока и ключа на потока в настройките на OBS и ще можете да започнете работа."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} messages"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} meddelelser"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Hvis du bruger vores egen zap.stream-hosting (billigst og nemmest), skal du kopiere din stream-URL og stream-nøgle til dine OBS-indstillinger, og så skulle du være klar til at gå i gang."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} Nachrichten"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Wenn du unser hauseigenes zap.stream-Hosting verwendest (am billigsten und einfachsten), kopiere deine Stream-URL und deinen Stream-Schlüssel in deine OBS-Einstellungen und schon solltest du startklar sein."
|
||||
},
|
||||
|
@ -73,6 +73,7 @@
|
||||
"Axo/o5": "Science & Technology",
|
||||
"AyGauy": "Login",
|
||||
"BD0vyn": "{name} created a clip",
|
||||
"BHNL+v": "Raw Data:",
|
||||
"Bd1yEX": "New Stream Goal",
|
||||
"Bep/gA": "Private key",
|
||||
"BzQPM+": "Destination",
|
||||
@ -176,6 +177,7 @@
|
||||
"RtYNX5": "Chat Users",
|
||||
"S39ba6": "What is OBS?",
|
||||
"SC2nJT": "Audio Codec",
|
||||
"Sjo1P4": "Custom",
|
||||
"TDUfVk": "Started",
|
||||
"TP/cMX": "Ended",
|
||||
"TcDwEB": "Stream Keys",
|
||||
@ -210,6 +212,7 @@
|
||||
"ZXp0z1": "Features",
|
||||
"ZaNcK4": "No goals yet",
|
||||
"ZcgtZo": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice.",
|
||||
"Zfr//4": "No duration provided, please try another upload server.",
|
||||
"ZmqxZs": "You can change this later",
|
||||
"ZsYhvh": "Zaps are lightning payments, which are published on nostr as receipts.",
|
||||
"Zse7yG": "Raid target",
|
||||
@ -231,6 +234,7 @@
|
||||
"dOQCL8": "Display name",
|
||||
"dVD/AR": "Top Zappers",
|
||||
"dkUMIH": "Clip by {name}",
|
||||
"dqGkI+": "Video durations vary too much, are you sure each variant is the same video?",
|
||||
"e011kf": "FAQ",
|
||||
"ebmhes": "Nostr Extension",
|
||||
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
|
||||
@ -240,6 +244,7 @@
|
||||
"gQxxlw": "Goal Name",
|
||||
"gt65Gg": "Stream goals encourage viewers to support streamers via donations.",
|
||||
"gzsn7k": "{n} messages",
|
||||
"h6NRY6": "No Thanks!",
|
||||
"h9mX2/": "If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go.",
|
||||
"hMzcSq": "Messages",
|
||||
"heyxZL": "Enable text to speech",
|
||||
@ -309,6 +314,7 @@
|
||||
"ug01Mk": "Time",
|
||||
"uksRSi": "Latest Videos",
|
||||
"vP4dFa": "Visit {link} to get some sweet zap.stream merch!",
|
||||
"vaZKTn": "Add more content",
|
||||
"vrTOHJ": "{amount} sats",
|
||||
"w+2Vw7": "Shorts",
|
||||
"w0Xm2F": "Start typing",
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} mensajes"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Si utiliza nuestro alojamiento interno zap.stream (el más barato y sencillo), copie la URL y la clave de transmisión en la configuración de OBS y ya puede empezar."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} پیام"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} viestiä"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Jos käytät sisäistä zap.stream-hostingia (halvin ja helpoin), kopioi stream URL-osoite ja Stream Key OBS-asetuksiisi, ja sinun pitäisi olla valmis."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} messages"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Si vous utilisez notre hébergement interne zap.stream (le moins cher et le plus simple), copiez l'URL et la clé de votre flux dans vos paramètres OBS et vous devriez être prêt."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} üzenetek"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Ha a házon belüli zap.stream tárhelyet használja (ez a legolcsóbb és legegyszerűbb), másolja be a stream URL-jét és a Stream Key-t az OBS beállításaiba, és máris készen áll."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} messaggi"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Se utilizzate il nostro hosting interno zap.stream (il più economico e semplice), copiate l'URL del vostro stream e la Stream Key nelle impostazioni di OBS e sarete pronti a partire."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} メッセージ"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "社内のzap.streamホスティング(最も安くて簡単)をご利用の場合は、ストリームURLとストリームキーをOBSの設定にコピーしてください。"
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} 메시지"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "가장 저렴하고 간편한 자체 zap.stream 호스팅을 사용하는 경우 스트림 URL과 스트림 키를 OBS 설정에 복사하면 바로 사용할 수 있습니다."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} berichten"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Als je onze in-house zap.stream hosting gebruikt (goedkoopste en makkelijkste), kopieer dan je stream URL en Stream Key naar je OBS instellingen en je zou aan de slag moeten kunnen."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} mensagens"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Se você usar nossa hospedagem interna zap.stream (mais barata e mais fácil), copie o URL da transmissão e a chave da transmissão para as configurações do OBS e estará pronto para começar."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} сообщения"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Если вы используете наш собственный хостинг zap.stream (самый дешевый и простой), скопируйте URL-адрес вашего потока и ключ потока в настройки OBS, и все будет готово."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} meddelanden"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Om du använder vår egen zap.stream-hosting (billigast och enklast) kopierar du din stream-URL och Stream Key till dina OBS-inställningar så är du redo att köra."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} Ujumbe"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "Ikiwa unatumia Hosting yetu ya ndani ya zap.stream (bei rahisi na rahisi zaidi), nakili URL yako ya Mtiririko na Ufunguo wako wa Mtiririko kwenye mipangilio yako ya OBS na utakuwa tayari kuanza."
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} ข้อความ"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "หากคุณใช้โฮสติ้ง zap.stream ของเรา (ถูกที่สุดและง่ายที่สุด) ให้คัดลอก URL สตรีมและคีย์สตรีมของคุณไปที่การตั้งค่า OBS เท่านี้คุณก็พร้อมแล้ว"
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} 信息"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "如果您使用我们内部的 zap.stream 托管(最便宜、最简单),请将您的流 URL 和流密钥复制到您的 OBS 设置中,然后就可以使用了。"
|
||||
},
|
||||
|
@ -730,6 +730,9 @@
|
||||
"gzsn7k": {
|
||||
"defaultMessage": "{n} 條消息"
|
||||
},
|
||||
"h6NRY6": {
|
||||
"defaultMessage": "No Thanks!"
|
||||
},
|
||||
"h9mX2/": {
|
||||
"defaultMessage": "If you use our in-house zap.stream hosting (cheapest and easiest), copy your stream URL and Stream Key to your OBS settings and you should be good to go."
|
||||
},
|
||||
|
44
yarn.lock
44
yarn.lock
@ -2573,14 +2573,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/system-react@npm:^1.5.6":
|
||||
version: 1.5.6
|
||||
resolution: "@snort/system-react@npm:1.5.6"
|
||||
"@snort/system-react@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@snort/system-react@npm:1.6.1"
|
||||
dependencies:
|
||||
"@snort/shared": "npm:^1.0.17"
|
||||
"@snort/system": "npm:^1.5.6"
|
||||
"@snort/system": "npm:^1.6.1"
|
||||
react: "npm:^18.2.0"
|
||||
checksum: 10c0/b7fb8dbc87328603b202dad9ce57dbff2cb8231829128b48346ba10925ee9fa5103652941b6a0456d6221fce8379a7148dc6507ebe8e1d0d35f7efd92738d08a
|
||||
checksum: 10c0/fe3180c1f4341df4fd585c4031ef632a7d59c7637c6aed16ea254a1c22a66e288516d96939c0080efd0fb73940531855a59a24fae5f82c326f6544362eb0394c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -2591,9 +2591,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/system@npm:^1.5.6":
|
||||
version: 1.5.6
|
||||
resolution: "@snort/system@npm:1.5.6"
|
||||
"@snort/system@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@snort/system@npm:1.6.1"
|
||||
dependencies:
|
||||
"@noble/ciphers": "npm:^0.6.0"
|
||||
"@noble/curves": "npm:^1.4.0"
|
||||
@ -2608,33 +2608,33 @@ __metadata:
|
||||
nostr-social-graph: "npm:^1.0.3"
|
||||
uuid: "npm:^9.0.0"
|
||||
ws: "npm:^8.14.0"
|
||||
checksum: 10c0/38fee2d55240f91a5e6ea0684a4bd94e6f4c56fab9b6a20d9ebef26dcdd17ed6c9a42bf1804c7bcf135a89c6882659baecede6d885dcead4a167d5e3337c9764
|
||||
checksum: 10c0/5da01450970f4a51df98369c4cdd2b8886add3480e7962d11ed17498cf7db421bcd5e585abe532ff754efc0bf26b5734b9b24005f958802357ec390ccb74d281
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/wallet@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "@snort/wallet@npm:0.2.1"
|
||||
"@snort/wallet@npm:^0.2.4":
|
||||
version: 0.2.4
|
||||
resolution: "@snort/wallet@npm:0.2.4"
|
||||
dependencies:
|
||||
"@cashu/cashu-ts": "npm:^1.0.0-rc.3"
|
||||
"@lightninglabs/lnc-web": "npm:^0.3.1-alpha"
|
||||
"@scure/base": "npm:^1.1.6"
|
||||
"@snort/shared": "npm:^1.0.17"
|
||||
"@snort/system": "npm:^1.5.6"
|
||||
"@snort/system": "npm:^1.6.1"
|
||||
debug: "npm:^4.3.4"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
checksum: 10c0/0dcf4b0336029e336bd6abcd7b79cf60d6fed08b2ab2847a8e791bb2399646e86c95aefc4dcfea08365c3dea417c362cdfa93939d7c36f037762de601936b331
|
||||
checksum: 10c0/7d3e23d1d79595ee99e041b816cec5e7bfc6f34bfce6f0864a556de82cfcf29200f6978744b0717b5f6873c5c498e2dd9005571171f1f533f235cfbadf205a44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/worker-relay@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "@snort/worker-relay@npm:1.3.0"
|
||||
"@snort/worker-relay@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "@snort/worker-relay@npm:1.3.1"
|
||||
dependencies:
|
||||
"@sqlite.org/sqlite-wasm": "npm:^3.46.1-build3"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
uuid: "npm:^9.0.1"
|
||||
checksum: 10c0/1a0eb175f50787bbcaa585641bf710347b59f3d3426cbf0f83182a5574bf7a63beb3e5d66bb41506e2d50c3ee904d55670c85c7f1542018936dd5a4ce06726e8
|
||||
checksum: 10c0/19b89e4f96df425d2d73e87fda1f82844bf7f3a1ba114073d0bf4052c9d5fe3eac9e6ca6d88ad8e36b65bae6dfcf69db5cb47828ef1c195419a94bd87ae2ff53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -7354,11 +7354,11 @@ __metadata:
|
||||
"@noble/hashes": "npm:^1.4.0"
|
||||
"@scure/base": "npm:^1.1.6"
|
||||
"@snort/shared": "npm:^1.0.17"
|
||||
"@snort/system": "npm:^1.5.6"
|
||||
"@snort/system-react": "npm:^1.5.6"
|
||||
"@snort/system": "npm:^1.6.1"
|
||||
"@snort/system-react": "npm:^1.6.1"
|
||||
"@snort/system-wasm": "npm:^1.0.5"
|
||||
"@snort/wallet": "npm:^0.2.1"
|
||||
"@snort/worker-relay": "npm:^1.3.0"
|
||||
"@snort/wallet": "npm:^0.2.4"
|
||||
"@snort/worker-relay": "npm:^1.3.1"
|
||||
"@szhsin/react-menu": "npm:^4.1.0"
|
||||
"@testing-library/dom": "npm:^9.3.1"
|
||||
"@types/node": "npm:^20.12.12"
|
||||
|
Loading…
x
Reference in New Issue
Block a user