feat: video player designs

This commit is contained in:
kieran 2024-05-22 13:51:23 +01:00
parent 91c0aeb22b
commit 6b6721edbc
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
22 changed files with 478 additions and 80 deletions

View File

@ -5,6 +5,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6",
"@snort/shared": "^1.0.15",
"@snort/system": "^1.3.2",
@ -23,6 +24,7 @@
"flag-icons": "^7.2.1",
"hls.js": "^1.5.8",
"marked": "^12.0.2",
"media-chrome": "^3.2.2",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.3.1",
"react-confetti": "^6.1.0",

View File

@ -13,8 +13,11 @@ export const MUTED = 10_000 as EventKind;
export const VIDEO_KIND = 34_235 as EventKind;
export const DAY = 60 * 60 * 24;
export const MINUTE = 60;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
export const MONTH = 30 * DAY;
export enum StreamState {
Live = "live",

View File

@ -2,15 +2,17 @@ import { HTMLProps, useState } from "react";
import classNames from "classnames";
import { getPlaceholder } from "@/utils";
import { UserMetadata } from "@snort/system";
import useImgProxy from "@/hooks/img-proxy";
type AvatarProps = HTMLProps<HTMLImageElement> & { size?: number; pubkey: string; user?: UserMetadata };
export function Avatar({ pubkey, size, user, ...props }: AvatarProps) {
const [failed, setFailed] = useState(false);
const src = user?.picture && !failed ? user.picture : getPlaceholder(pubkey);
const { proxy } = useImgProxy();
const src = user?.picture && !failed ? proxy(user.picture, size ?? 40) : getPlaceholder(pubkey);
return (
<img
{...props}
className={classNames("aspect-square rounded-full bg-layer-1", props.className)}
className={classNames("aspect-square rounded-full bg-layer-1 object-cover", props.className)}
alt={user?.name}
src={src}
onError={() => setFailed(true)}

View File

@ -1,5 +1,5 @@
import { formatSats } from "@/number";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { EventKind, NostrLink, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useReactions, useEventReactions, SnortContext } from "@snort/system-react";
import { Icon } from "./icon";
import { useLogin } from "@/hooks/login";
@ -7,21 +7,54 @@ import AsyncButton from "./async-button";
import { useContext } from "react";
import { ZapEvent } from "./send-zap";
export default function EventReactions({ ev }: { ev: TaggedNostrEvent }) {
export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent; replyKind?: EventKind }) {
const link = NostrLink.fromEvent(ev)!;
const login = useLogin();
const system = useContext(SnortContext);
const reactions = useReactions(`reactions:${link.id}`, [link]);
const grouped = useEventReactions(link, reactions);
const didReact = (evs: TaggedNostrEvent[] | ParsedZap[], kind: EventKind) => {
if (evs.length === 0) return false;
if ("amount" in evs[0]) {
return (evs as ParsedZap[]).some(a => a.sender === login?.pubkey);
} else {
return (evs as TaggedNostrEvent[]).some(a => a.pubkey === login?.pubkey && a.kind === kind);
}
};
const reactedIcon = (
name: string,
nameReacted: string,
classReacted: string,
evs: Array<TaggedNostrEvent> | Array<ParsedZap>,
kind: EventKind,
) => {
const r = didReact(evs, kind);
return <Icon name={r ? nameReacted : name} className={r ? classReacted : undefined} />;
};
const pub = login?.publisher();
const totalZaps = grouped.zaps.reduce((acc, v) => acc + v.amount, 0);
const iconClass = "flex gap-2 items-center tabular-nums cursor-pointer select-none hover:text-primary transition";
return (
<div className="flex flex-wrap gap-4 mt-2 items-center">
<div className="flex flex-wrap gap-4 mt-2 items-center text-neutral-500">
{replyKind && (
<AsyncButton
className={iconClass}
onClick={async () => {
if (pub) {
const evReact = await pub.react(ev);
await system.BroadcastEvent(evReact);
}
}}>
<Icon name="message-circle" />
{grouped.replies.length > 0 ? grouped.replies.length : undefined}
</AsyncButton>
)}
<ZapEvent ev={ev}>
<div className={iconClass}>
<Icon name="zap-filled" />
{reactedIcon("zap", "zap-filled", "text-zap", grouped.zaps, EventKind.ZapReceipt)}
{totalZaps > 0 ? formatSats(totalZaps) : undefined}
</div>
</ZapEvent>
@ -33,7 +66,7 @@ export default function EventReactions({ ev }: { ev: TaggedNostrEvent }) {
await system.BroadcastEvent(evReact);
}
}}>
<Icon name="heart-solid" />
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : undefined}
</AsyncButton>
<AsyncButton

View File

@ -0,0 +1,16 @@
import classNames from "classnames";
import { HTMLProps } from "react";
export default function PillOpaque({ children, selected, className, ...props }: HTMLProps<HTMLDivElement>) {
return (
<div className="relative overflow-hidden px-2 py-1 cursor-pointer text-sm">
<div
{...props}
className={classNames(
{ "bg-layer-3 font-bold": selected },
"absolute w-full h-full top-0 left-0 font-semibold rounded-lg bg-layer-2 opacity-60",
)}></div>
<div className={classNames(className, "relative")}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { DAY, HOUR, MINUTE, MONTH, WEEK } from "@/const";
import { FormattedMessage } from "react-intl";
export function RelativeTime({ from }: { from: number }) {
const diff = (new Date().getTime() - from) / 1000;
if (diff > MONTH) {
return (
<FormattedMessage
defaultMessage="{m}mo"
description="Number of month(s) relative to now"
values={{ m: Math.floor(diff / MONTH).toFixed(0) }}
/>
);
} else if (diff > WEEK) {
return (
<FormattedMessage
defaultMessage="{m}w"
description="Number of week(s) relative to now"
values={{ m: Math.floor(diff / WEEK).toFixed(0) }}
/>
);
} else if (diff > DAY) {
return (
<FormattedMessage
defaultMessage="{m}d"
description="Number of day(s) relative to now"
values={{ m: Math.floor(diff / DAY).toFixed(0) }}
/>
);
} else if (diff > HOUR) {
return (
<FormattedMessage
defaultMessage="{m}h"
description="Number of hour(s) relative to now"
values={{ m: Math.floor(diff / HOUR).toFixed(0) }}
/>
);
} else if (diff > MINUTE) {
return (
<FormattedMessage
defaultMessage="{m}h"
description="Number of minute(s) relative to now"
values={{ m: Math.floor(diff / MINUTE).toFixed(0) }}
/>
);
} else {
return (
<FormattedMessage
defaultMessage="{m}s"
description="Number of second(s) relative to now"
values={{ m: Math.floor(diff).toFixed(0) }}
/>
);
}
}

View File

@ -4,7 +4,7 @@ import classNames from "classnames";
import { StreamState } from "@/const";
import Pill from "./pill";
type StatePillProps = { state: StreamState } & HTMLProps<HTMLSpanElement>;
type StatePillProps = { state: StreamState } & HTMLProps<HTMLDivElement>;
export function StatePill({ state, ...props }: StatePillProps) {
return (

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { findTag } from "@/utils";
import { HOUR, MINUTE } from "@/const";
export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const [time, setTime] = useState("");
@ -9,12 +10,10 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
function updateTime() {
const starts = Number(findTag(ev, "starts") ?? unixNow());
const diff = unixNow() - starts;
const min = 60;
const hour = min * 60;
const hours = Math.floor(diff / hour);
const mins = Math.floor((diff % hour) / min);
const secs = Math.floor(diff % min);
const hours = Math.floor(diff / HOUR);
const mins = Math.floor((diff % HOUR) / MINUTE);
const secs = Math.floor(diff % MINUTE);
setTime(
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}:${secs.toFixed(0).padStart(2, "0")}`,
);

View File

@ -73,7 +73,7 @@ export default function VideoGridSorted({
{live
.filter(e => !mutedHosts.has(getHost(e)))
.map(e => (
<VideoTile ev={e} key={e.id} />
<VideoTile ev={e} key={e.id} style="grid" />
))}
</VideoGrid>
)}
@ -104,7 +104,7 @@ function GridSection({ header, items }: { header: ReactNode; items: Array<Tagged
</div>
<VideoGrid>
{items.map(e => (
<VideoTile ev={e} key={e.id} />
<VideoTile ev={e} key={e.id} style="grid" />
))}
</VideoGrid>
</>

View File

@ -7,32 +7,45 @@ import { StatePill } from "./state-pill";
import { extractStreamInfo, getHost, profileLink } from "@/utils";
import { formatSats } from "@/number";
import { StreamState } from "@/const";
import Pill from "./pill";
import classNames from "classnames";
import Logo from "./logo";
import { useContentWarning } from "./nsfw";
import { useState } from "react";
import { Avatar } from "./avatar";
import { useUserProfile } from "@snort/system-react";
import { VideoDuration } from "./video/duration";
import useImgProxy from "@/hooks/img-proxy";
import PillOpaque from "./pill-opaque";
export function VideoTile({
ev,
showAuthor = true,
showStatus = true,
showAvatar = true,
style,
className,
}: {
ev: NostrEvent;
showAuthor?: boolean;
showStatus?: boolean;
showAvatar?: boolean;
style: "list" | "grid";
className?: string;
}) {
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
const { title, image, status, participants, contentWarning, duration, recording } = extractStreamInfo(ev);
const host = getHost(ev);
const hostProfile = useUserProfile(host);
const isGrownUp = useContentWarning();
const { proxy } = useImgProxy();
const link = NostrLink.fromEvent(ev);
const [hasImg, setHasImage] = useState((image?.length ?? 0) > 0);
const [hasImg, setHasImage] = useState((image?.length ?? 0) > 0 || (recording?.length ?? 0) > 0);
return (
<div className="flex flex-col gap-2">
<div
className={classNames("flex gap-2", className, {
"flex-col": style === "grid",
"flex-row": style === "list",
})}>
<Link
to={`/${link.encode()}`}
className={classNames(
@ -43,12 +56,12 @@ export function VideoTile({
"h-full",
)}
state={ev}>
<div className="relative mb-2 aspect-video">
<div className="h-inherit relative aspect-video bg-layer-1 rounded-xl overflow-hidden">
{hasImg ? (
<img
loading="lazy"
className="aspect-video object-cover rounded-xl"
src={image}
className="w-full h-inherit object-fit"
src={proxy(image ?? recording ?? "")}
onError={() => {
setHasImage(false);
}}
@ -56,24 +69,31 @@ export function VideoTile({
) : (
<Logo className="text-white aspect-video" />
)}
<span className="flex flex-col justify-between absolute top-0 h-full right-4 items-end py-2">
<span className="flex flex-col justify-between absolute top-0 h-full right-2 items-end py-2">
{showStatus && <StatePill state={status as StreamState} />}
{participants && (
<Pill>
<PillOpaque>
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(Number(participants)) }} />
</Pill>
</PillOpaque>
)}
{duration && (
<PillOpaque>
<VideoDuration value={duration} />
</PillOpaque>
)}
</span>
</div>
</Link>
<div className="flex gap-3">
{showAuthor && (
{showAuthor && showAvatar && (
<Link to={profileLink(hostProfile, host)}>
<Avatar pubkey={host} user={hostProfile} />
</Link>
)}
<div className="flex flex-col">
<span className="font-medium">{title}</span>
<span className="font-medium" title={title}>
{(title?.length ?? 0) > 50 ? `${title?.slice(0, 47)}...` : title}
</span>
{showAuthor && <span className="text-layer-4">{getName(host, hostProfile)}</span>}
</div>
</div>

View File

@ -3,6 +3,7 @@ import { Profile, getName } from "../profile";
import { Text } from "@/element/text";
import { useUserProfile } from "@snort/system-react";
import EventReactions from "../event-reactions";
import { RelativeTime } from "../relative-time";
export default function VideoComment({ ev }: { ev: TaggedNostrEvent }) {
const profile = useUserProfile(ev.pubkey);
@ -16,10 +17,15 @@ export default function VideoComment({ ev }: { ev: TaggedNostrEvent }) {
showName: false,
}}
/>
<div className="flex flex-col">
<div className="text-medium">{getName(ev.pubkey, profile)}</div>
<div className="flex flex-col gap-1">
<div className="font-medium flex gap-2 items-center">
<div>{getName(ev.pubkey, profile)}</div>
<div className="text-neutral-500 text-sm">
<RelativeTime from={ev.created_at * 1000} />
</div>
</div>
<Text content={ev.content} tags={ev.tags} />
<EventReactions ev={ev} />
<EventReactions ev={ev} replyKind={1} />
</div>
</div>
);

View File

@ -0,0 +1,18 @@
import { HOUR, MINUTE } from "@/const";
export function VideoDuration({ value }: { value: number }) {
// array of time parts, [seconds, minutes, {hours}]
const parts: Array<number> = [0, 0];
parts[0] = Math.floor(value % MINUTE);
parts[1] = Math.floor((value % HOUR) / MINUTE);
const hours = Math.floor(value / HOUR);
if (hours >= 1) {
parts.push(hours);
}
return parts
.reverse()
.map(a => a.toFixed().padStart(2, "0"))
.join(":");
}

View File

@ -0,0 +1,32 @@
import { MediaChromeButton } from "media-chrome";
import React, { HTMLProps, HtmlHTMLAttributes } from "react";
const renditionIcon = /*html*/ `<svg viewBox="0 0 24 24" fill="none">
<path d="M14 22H6.8M6.8 22C5.11984 22 4.27976 22 3.63803 21.673C3.07354 21.3854 2.6146 20.9265 2.32698 20.362C2 19.7202 2 18.8802 2 17.2M6.8 22H7.2C8.88016 22 9.72024 22 10.362 21.673C10.9265 21.3854 11.3854 20.9265 11.673 20.362C12 19.7202 12 18.8802 12 17.2V16.8C12 15.1198 12 14.2798 11.673 13.638C11.3854 13.0735 10.9265 12.6146 10.362 12.327C9.72024 12 8.88016 12 7.2 12H6.8C5.11984 12 4.27976 12 3.63803 12.327C3.07354 12.6146 2.6146 13.0735 2.32698 13.638C2 14.2798 2 15.1198 2 16.8V17.2M2 17.2V10M10 2H14M22 10V14M18 22C18.93 22 19.395 22 19.7765 21.8978C20.8117 21.6204 21.6204 20.8117 21.8978 19.7765C22 19.395 22 18.93 22 18M22 6C22 5.07003 22 4.60504 21.8978 4.22354C21.6204 3.18827 20.8117 2.37962 19.7765 2.10222C19.395 2 18.93 2 18 2M6 2C5.07003 2 4.60504 2 4.22354 2.10222C3.18827 2.37962 2.37962 3.18827 2.10222 4.22354C2 4.60504 2 5.07003 2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
const slotTemplate = document.createElement("template");
slotTemplate.innerHTML = /*html*/ `
<slot name="icon">${renditionIcon}</slot>
`;
class MediaPlayerSizeButton extends MediaChromeButton {
static get observedAttributes() {
return [...super.observedAttributes];
}
constructor() {
super({ slotTemplate });
}
}
if (!globalThis.customElements.get("media-player-size-button")) {
globalThis.customElements.define("media-player-size-button", MediaPlayerSizeButton);
}
const MediaPlayerSizeButtonReact = React.forwardRef<HTMLElement, HTMLProps<HTMLElement>>((props, ref) => {
return React.createElement("media-player-size-button", { ...props, suppressHydrationWarning: true, ref });
});
export { MediaPlayerSizeButtonReact };

60
src/hooks/img-proxy.ts Normal file
View File

@ -0,0 +1,60 @@
import * as utils from "@noble/curves/abstract/utils";
import { base64 } from "@scure/base";
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha256";
import { unwrap } from "@snort/shared";
export const DefaultImgProxy = {
url: "https://imgproxy.snort.social",
key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942",
salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b",
};
export function hmacSha256(key: Uint8Array, ...messages: Uint8Array[]) {
return hmac(sha256, key, utils.concatBytes(...messages));
}
export interface ImgProxySettings {
url: string;
key: string;
salt: string;
}
export default function useImgProxy() {
const imgProxyConfig = DefaultImgProxy;
return {
proxy: (url: string, resize?: number, sha256?: string) => proxyImg(url, imgProxyConfig, resize, sha256),
};
}
export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number, sha256?: string) {
const te = new TextEncoder();
function urlSafe(s: string) {
return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function signUrl(u: string) {
const result = hmacSha256(
utils.hexToBytes(unwrap(settings).key),
utils.hexToBytes(unwrap(settings).salt),
te.encode(u),
);
return urlSafe(base64.encode(result));
}
if (!settings) return url;
if (url.startsWith("data:") || url.startsWith("blob:") || url.length == 0) return url;
const opts = [];
if (sha256) {
opts.push(`hs:sha256:${sha256}`);
}
if (resize) {
opts.push(`rs:fit:${resize}:${resize}`);
opts.push(`dpr:${window.devicePixelRatio}`);
}
const urlBytes = te.encode(url);
const urlEncoded = urlSafe(base64.encode(urlBytes));
const path = `/${opts.join("/")}/${urlEncoded}`;
const sig = signUrl(path);
return `${new URL(settings.url).toString()}${sig}${path}`;
}

View File

@ -18,6 +18,9 @@
<symbol id="message" viewBox="0 0 18 16" fill="none">
<path d="M7.75036 8.00004H3.16702M3.09648 8.24296L1.15071 14.0552C0.997847 14.5118 0.921417 14.7401 0.976267 14.8807C1.0239 15.0028 1.1262 15.0954 1.25244 15.1306C1.3978 15.1712 1.61736 15.0724 2.05647 14.8748L15.9827 8.60799C16.4113 8.41512 16.6256 8.31868 16.6918 8.18471C16.7494 8.06832 16.7494 7.93176 16.6918 7.81537C16.6256 7.6814 16.4113 7.58497 15.9827 7.39209L2.05161 1.12314C1.61383 0.926139 1.39493 0.827637 1.24971 0.868044C1.1236 0.903136 1.0213 0.995457 0.973507 1.11733C0.91847 1.25766 0.994084 1.48547 1.14531 1.9411L3.09702 7.82131C3.12299 7.89957 3.13598 7.9387 3.14111 7.97871C3.14565 8.01422 3.14561 8.05017 3.14097 8.08567C3.13574 8.12567 3.12265 8.16477 3.09648 8.24296Z" stroke="currentColor" stroke-opacity="0.5" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="message-circle" viewBox="0 0 14 14" fill="none">
<path d="M13 7C13 10.3137 10.3137 13 7 13C6.2019 13 5.4402 12.8442 4.74366 12.5613C4.61035 12.5072 4.54369 12.4801 4.48981 12.468C4.43711 12.4562 4.3981 12.4519 4.34409 12.4519C4.28887 12.4519 4.22872 12.4619 4.10843 12.4819L1.73651 12.8772C1.48812 12.9186 1.36393 12.9393 1.27412 12.9008C1.19552 12.8671 1.13289 12.8045 1.09917 12.7259C1.06065 12.6361 1.08135 12.5119 1.12275 12.2635L1.51807 9.89157C1.53812 9.77128 1.54814 9.71113 1.54814 9.65591C1.54813 9.6019 1.54381 9.56289 1.532 9.51019C1.51992 9.45631 1.49285 9.38965 1.43871 9.25634C1.15582 8.5598 1 7.7981 1 7C1 3.68629 3.68629 1 7 1C10.3137 1 13 3.68629 13 7Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="login" viewBox="0 0 18 18" fill="none">
<path d="M4 13.1667C4 13.4594 4 13.6058 4.01306 13.7331C4.12146 14.7895 4.8855 15.6622 5.91838 15.9093C6.04279 15.939 6.18792 15.9584 6.47807 15.9971L11.9713 16.7295C13.535 16.938 14.3169 17.0423 14.9237 16.801C15.4565 16.5891 15.9002 16.2006 16.1806 15.7005C16.5 15.1309 16.5 14.3421 16.5 12.7646V5.23541C16.5 3.65787 16.5 2.8691 16.1806 2.2995C15.9002 1.7994 15.4565 1.41088 14.9237 1.19904C14.3169 0.957756 13.535 1.062 11.9713 1.2705L6.47807 2.00293C6.18788 2.04162 6.04279 2.06097 5.91838 2.09073C4.8855 2.33781 4.12145 3.21049 4.01306 4.26696C4 4.39421 4 4.54059 4 4.83334M9 5.66668L12.3333 9.00001M12.3333 9.00001L9 12.3333M12.3333 9.00001H1.5" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
@ -144,11 +147,14 @@
<symbol id="twitter" viewBox="0 0 24 20" fill="none">
<path d="M24 2.60005C23.1 3.00005 22.2 3.30005 21.2 3.40005C22.2 2.80005 23 1.80005 23.4 0.700049C22.4 1.30005 21.4 1.70005 20.3 1.90005C19.4 0.900049 18.1 0.300049 16.7 0.300049C14 0.300049 11.8 2.50005 11.8 5.20005C11.8 5.60005 11.8 6.00005 11.9 6.30005C7.7 6.10005 4.1 4.10005 1.7 1.10005C1.2 1.90005 1 2.70005 1 3.60005C1 5.30005 1.9 6.80005 3.2 7.70005C2.4 7.70005 1.6 7.50005 1 7.10005C1 7.10005 1 7.10005 1 7.20005C1 9.60005 2.7 11.6 4.9 12C4.5 12.1 4.1 12.2 3.6 12.2C3.3 12.2 3 12.2 2.7 12.1C3.3 14.1 5.1 15.5 7.3 15.5C5.6 16.8 3.5 17.6 1.2 17.6C0.8 17.6 0.4 17.6 0 17.5C2.2 18.9 4.8 19.7001 7.5 19.7001C16.6 19.7001 21.5 12.2 21.5 5.70005C21.5 5.50005 21.5 5.30005 21.5 5.10005C22.5 4.40005 23.3 3.50005 24 2.60005Z" fill="currentColor"/>
</symbol>
<symbol id="heart" viewBox="0 0 16 14" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99543 2.42388C6.66253 0.8656 4.43983 0.446428 2.76979 1.87334C1.09976 3.30026 0.86464 5.68598 2.17613 7.3736C3.26654 8.77674 6.56651 11.7361 7.64806 12.6939C7.76906 12.801 7.82957 12.8546 7.90014 12.8757C7.96173 12.8941 8.02913 12.8941 8.09072 12.8757C8.16129 12.8546 8.22179 12.801 8.3428 12.6939C9.42435 11.7361 12.7243 8.77674 13.8147 7.3736C15.1262 5.68598 14.9198 3.28525 13.2211 1.87334C11.5223 0.461438 9.32833 0.8656 7.99543 2.42388Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="heart-solid" viewBox="0 0 19 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.49466 2.78774C7.73973 1.25408 5.14439 0.940234 3.12891 2.6623C0.948817 4.52502 0.63207 7.66213 2.35603 9.88052C3.01043 10.7226 4.28767 11.9877 5.51513 13.1462C6.75696 14.3184 7.99593 15.426 8.60692 15.9671C8.61074 15.9705 8.61463 15.9739 8.61859 15.9774C8.67603 16.0283 8.74753 16.0917 8.81608 16.1433C8.89816 16.2052 9.01599 16.2819 9.17334 16.3288C9.38253 16.3912 9.60738 16.3912 9.81656 16.3288C9.97391 16.2819 10.0917 16.2052 10.1738 16.1433C10.2424 16.0917 10.3139 16.0283 10.3713 15.9774C10.3753 15.9739 10.3792 15.9705 10.383 15.9671C10.994 15.426 12.2329 14.3184 13.4748 13.1462C14.7022 11.9877 15.9795 10.7226 16.6339 9.88052C18.3512 7.67065 18.0834 4.50935 15.8532 2.65572C13.8153 0.961905 11.2476 1.25349 9.49466 2.78774Z" fill="currentColor"/>
</symbol>
<symbol id="repost" viewBox="0 0 22 20" fill="none">
<path d="M1 12C1 12 1.12132 12.8492 4.63604 16.364C8.15076 19.8787 13.8492 19.8787 17.364 16.364C18.6092 15.1187 19.4133 13.5993 19.7762 12M1 12V18M1 12H7M21 8C21 8 20.8787 7.15076 17.364 3.63604C13.8492 0.12132 8.15076 0.12132 4.63604 3.63604C3.39076 4.88131 2.58669 6.40072 2.22383 8M21 8V2M21 8H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<symbol id="repost" viewBox="0 0 16 16" fill="none">
<path d="M8.66666 14.6666L6.66666 12.6666M6.66666 12.6666L8.66666 10.6666M6.66666 12.6666H10C12.5773 12.6666 14.6667 10.5772 14.6667 7.99992C14.6667 6.13832 13.5766 4.53132 12 3.78234M3.99999 12.2175C2.42336 11.4685 1.33333 9.86152 1.33333 7.99992C1.33333 5.42259 3.42267 3.33325 6 3.33325H9.33333M9.33333 3.33325L7.33333 1.33325M9.33333 3.33325L7.33333 5.33325" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="dice" viewBox="0 0 34 34" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4454 0.333009H6.98362C6.10506 0.332981 5.34708 0.332956 4.72281 0.383961C4.0639 0.437796 3.40854 0.556656 2.7779 0.877979C1.83709 1.35735 1.07219 2.12225 0.592823 3.06306C0.2715 3.69369 0.15264 4.34906 0.0988048 5.00797C0.0477998 5.63224 0.0478244 6.39015 0.0478529 7.26871V26.7306C0.0478244 27.6091 0.0477998 28.3671 0.0988048 28.9914C0.15264 29.6503 0.2715 30.3057 0.592823 30.9363C1.07219 31.8771 1.83709 32.642 2.7779 33.1214C3.40854 33.4427 4.0639 33.5616 4.72281 33.6154C5.34709 33.6664 6.10501 33.6664 6.98358 33.6663H26.4455C27.324 33.6664 28.082 33.6664 28.7062 33.6154C29.3651 33.5616 30.0205 33.4427 30.6511 33.1214C31.592 32.642 32.3569 31.8771 32.8362 30.9363C33.1575 30.3057 33.2764 29.6503 33.3302 28.9914C33.3812 28.3671 33.3812 27.6092 33.3812 26.7306V7.26873C33.3812 6.39017 33.3812 5.63224 33.3302 5.00797C33.2764 4.34906 33.1575 3.69369 32.8362 3.06306C32.3569 2.12225 31.592 1.35735 30.6511 0.877979C30.0205 0.556656 29.3651 0.437796 28.7062 0.383961C28.082 0.332956 27.324 0.332981 26.4454 0.333009ZM21.7145 9.49968C21.7145 8.11896 22.8338 6.99968 24.2145 6.99968C25.5952 6.99968 26.7145 8.11896 26.7145 9.49968C26.7145 10.8804 25.5952 11.9997 24.2145 11.9997C22.8338 11.9997 21.7145 10.8804 21.7145 9.49968ZM14.2145 16.9997C14.2145 15.619 15.3338 14.4997 16.7145 14.4997C18.0952 14.4997 19.2145 15.619 19.2145 16.9997C19.2145 18.3804 18.0952 19.4997 16.7145 19.4997C15.3338 19.4997 14.2145 18.3804 14.2145 16.9997ZM9.21452 21.9997C7.83381 21.9997 6.71452 23.119 6.71452 24.4997C6.71452 25.8804 7.83381 26.9997 9.21452 26.9997C10.5952 26.9997 11.7145 25.8804 11.7145 24.4997C11.7145 23.119 10.5952 21.9997 9.21452 21.9997ZM24.2145 21.9997C25.5952 21.9997 26.7145 23.119 26.7145 24.4997C26.7145 25.8804 25.5952 26.9997 24.2145 26.9997C22.8338 26.9997 21.7145 25.8804 21.7145 24.4997C21.7145 23.119 22.8338 21.9997 24.2145 21.9997ZM11.7145 9.49968C11.7145 8.11896 10.5952 6.99968 9.21452 6.99968C7.83381 6.99968 6.71452 8.11896 6.71452 9.49968C6.71452 10.8804 7.83381 11.9997 9.21452 11.9997C10.5952 11.9997 11.7145 10.8804 11.7145 9.49968Z" fill="currentColor"/>

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -62,6 +62,10 @@
"1qsXCO": {
"defaultMessage": "eg. name@wallet.com"
},
"1y8vnu": {
"defaultMessage": "{m}s",
"description": "Number of second(s) relative to now"
},
"2/2yg+": {
"defaultMessage": "Add"
},
@ -173,6 +177,10 @@
"9a9+ww": {
"defaultMessage": "Title"
},
"9bAnRw": {
"defaultMessage": "{m}d",
"description": "Number of day(s) relative to now"
},
"9pMqYs": {
"defaultMessage": "Nostr Address"
},
@ -461,6 +469,10 @@
"TDUfVk": {
"defaultMessage": "Started"
},
"TNFpMZ": {
"defaultMessage": "{m}h",
"description": "Number of hour(s) relative to now"
},
"TP/cMX": {
"defaultMessage": "Ended"
},
@ -498,12 +510,19 @@
"defaultMessage": "Value",
"description": "Config value column header"
},
"Wp4l7+": {
"defaultMessage": "More Videos"
},
"WsjXrZ": {
"defaultMessage": "Click on Log In"
},
"X2PZ7D": {
"defaultMessage": "Create Goal"
},
"XD/ATb": {
"defaultMessage": "{m}mo",
"description": "Number of month(s) relative to now"
},
"XIvYvF": {
"defaultMessage": "Failed to get invoice"
},
@ -662,6 +681,10 @@
"jgOqxt": {
"defaultMessage": "Widgets"
},
"jhTFev": {
"defaultMessage": "{m}w",
"description": "Number of week(s) relative to now"
},
"jkAQj5": {
"defaultMessage": "Stream Ended"
},
@ -737,6 +760,10 @@
"q9ryv4": {
"defaultMessage": "Cover image URL (optional)"
},
"qRV9H5": {
"defaultMessage": "{m}h",
"description": "Number of minute(s) relative to now"
},
"qx6bv2": {
"defaultMessage": "Stream Goal (optional)"
},
@ -798,6 +825,9 @@
"uYw2LD": {
"defaultMessage": "Stream"
},
"uksRSi": {
"defaultMessage": "Latest Videos"
},
"vrTOHJ": {
"defaultMessage": "{amount} sats"
},

View File

@ -24,6 +24,7 @@ import { TopZappers } from "@/element/top-zappers";
import { useProfileClips } from "@/hooks/clips";
import VideoGrid from "@/element/video-grid";
import { ClipTile } from "@/element/stream/clip-tile";
import useImgProxy from "@/hooks/img-proxy";
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
@ -32,17 +33,18 @@ export function ProfilePage() {
const link = parseNostrLink(unwrap(params.npub));
const { streams, zaps } = useProfile(link, true);
const profile = useUserProfile(link.id);
const { proxy } = useImgProxy();
const pastStreams = useMemo(() => {
return streams.filter(ev => findTag(ev, "status") === StreamState.Ended);
}, [streams]);
return (
<div className="flex flex-col gap-3 xl:px-4">
<div className="flex flex-col gap-3 xl:px-4 w-full">
<img
className="rounded-xl object-cover h-[360px]"
alt={profile?.name || link.id}
src={profile?.banner ? profile?.banner : defaultBanner}
src={profile?.banner ? proxy(profile?.banner) : defaultBanner}
/>
<ProfileHeader link={link} profile={profile} streams={streams} />
<div className="grid lg:grid-cols-2 gap-4 py-2">
@ -145,7 +147,7 @@ function ProfileStreamList({ streams }: { streams: Array<TaggedNostrEvent> }) {
<VideoGrid>
{streams.map(ev => (
<div key={ev.id} className="flex flex-col gap-1">
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
<VideoTile ev={ev} showAuthor={false} showStatus={false} style="grid" />
<span className="text-neutral-500">
<FormattedMessage
defaultMessage="Streamed on {date}"

View File

@ -1,4 +1,3 @@
import { Textarea } from "@/element/chat/textarea";
import { WriteMessage } from "@/element/chat/write-message";
import { FollowButton } from "@/element/follow-button";
import { Profile, getName } from "@/element/profile";
@ -7,60 +6,151 @@ import { ShareMenu } from "@/element/share-menu";
import { StreamSummary } from "@/element/stream/summary";
import VideoComments from "@/element/video/comments";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { getHost, extractStreamInfo } from "@/utils";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import useImgProxy from "@/hooks/img-proxy";
import { getHost, extractStreamInfo, findTag } from "@/utils";
import { NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import {
MediaController,
MediaControlBar,
MediaTimeRange,
MediaTimeDisplay,
MediaVolumeRange,
MediaPlayButton,
MediaMuteButton,
MediaFullscreenButton,
MediaPipButton,
MediaPlaybackRateButton,
} from "media-chrome/react";
import { MediaPlayerSizeButtonReact } from "@/element/video/video-size-button";
import { useEffect, useMemo, useState } from "react";
import classNames from "classnames";
import { useMediaQuery } from "usehooks-ts";
import { VideoTile } from "@/element/video-tile";
import { VIDEO_KIND } from "@/const";
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const { formatMessage } = useIntl();
const ev = useCurrentStreamFeed(link, true, evPreload);
const [newComment, setNewComment] = useState("");
const host = getHost(ev);
const [widePlayer, setWidePlayer] = useState(localStorage.getItem("wide-player") === "true");
const { title, summary, image, contentWarning, recording } = extractStreamInfo(ev);
const profile = useUserProfile(host);
const { proxy } = useImgProxy();
const zapTarget = profile?.lud16 ?? profile?.lud06;
const isDesktop = useMediaQuery("(min-width: 1280px)");
useEffect(() => {
localStorage.setItem("wide-player", String(widePlayer));
}, [widePlayer]);
return (
<div className="p-4 w-[80dvw] mx-auto">
<video src={recording} controls className="w-full aspect-video" poster={image} />
<div className="grid grid-cols-[auto_450px]">
<div className="flex flex-col gap-4">
<div className="font-medium text-xl">{title}</div>
<div className="flex justify-between">
{/* PROFILE SECTION */}
<div className="flex gap-2 items-center">
<Profile pubkey={host} />
<FollowButton pubkey={host} />
</div>
{/* ACTIONS */}
<div className="flex gap-2">
{ev && (
<>
<ShareMenu ev={ev} />
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={link.tagKey}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
<div
className={classNames("lg:p-4 grow lg:grid lg:gap-2 lg:grid-cols-[auto_450px]", {
"max-w-[60dvw] mx-auto": !widePlayer,
})}>
<div
className={classNames("min-w-0 w-full max-h-[80dvh] aspect-video mx-auto bg-black", {
"col-span-2": widePlayer,
})}>
<MediaController className="min-w-0 w-full" mediaStreamType="on-demand">
<video
className="max-h-[80dvh] aspect-video"
slot="media"
src={recording}
autoPlay={true}
controls={false}
poster={proxy(image ?? recording ?? "")}
/>
<MediaControlBar>
<MediaPlayButton />
<MediaPlaybackRateButton />
<MediaTimeRange />
<MediaTimeDisplay showDuration></MediaTimeDisplay>
<MediaMuteButton />
<MediaVolumeRange />
<MediaPipButton />
<MediaFullscreenButton />
{isDesktop && <MediaPlayerSizeButtonReact onClick={() => setWidePlayer(w => !w)} />}
</MediaControlBar>
</MediaController>
</div>
{/* VIDEO INFO & COMMENTS */}
<div
className={classNames("row-start-2 col-start-1 max-xl:px-4 flex flex-col gap-4", {
"mx-auto w-[40dvw]": widePlayer,
})}>
<div className="font-medium text-xl">{title}</div>
<div className="flex justify-between">
{/* PROFILE SECTION */}
<div className="flex gap-2 items-center">
<Profile pubkey={host} />
<FollowButton pubkey={host} />
</div>
{summary && <StreamSummary text={summary} />}
<h3>
<FormattedMessage defaultMessage="Comments" />
</h3>
<div>
<WriteMessage link={link} emojiPacks={[]} kind={1} />
{/* ACTIONS */}
<div className="flex gap-2">
{ev && (
<>
<ShareMenu ev={ev} />
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={link.tagKey}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
<VideoComments link={link} />
</div>
{summary && <StreamSummary text={summary} />}
<h3>
<FormattedMessage defaultMessage="Comments" />
</h3>
<div>
<WriteMessage link={link} emojiPacks={[]} kind={1} />
</div>
<VideoComments link={link} />
</div>
<div
className={classNames("p-2 col-start-2", {
"row-start-1 row-span-3": !widePlayer,
"row-start-2": widePlayer,
})}>
<UpNext pubkey={host} exclude={[link]} />
</div>
</div>
);
}
function UpNext({ pubkey, exclude }: { pubkey: string; exclude: Array<NostrLink> }) {
const rb = new RequestBuilder(`videos:${pubkey}`);
rb.withFilter().kinds([VIDEO_KIND]);
const videos = useRequestBuilder(rb);
const sorted = useMemo(
() =>
videos
.filter(a => !exclude.some(b => b.equals(NostrLink.fromEvent(a))))
.sort((a, b) => {
const pubA = findTag(a, "published_at");
const pubB = findTag(b, "published_at");
return Number(pubA) > Number(pubB) ? -1 : 1;
})
.slice(0, 10),
[videos],
);
return (
<div className="flex flex-col gap-2">
<h3>
<FormattedMessage defaultMessage="More Videos" />
</h3>
{sorted.map(a => (
<VideoTile ev={a} key={a.id} showStatus={false} style="list" className="h-[100px]" showAvatar={false} />
))}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { VideoTile } from "@/element/video-tile";
import { findTag } from "@/utils";
import { RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
export function VideosPage() {
const rb = new RequestBuilder("videos");
@ -19,9 +20,13 @@ export function VideosPage() {
return (
<div className="p-4">
<h2>
<FormattedMessage defaultMessage="Latest Videos" />
</h2>
<br />
<VideoGrid>
{sorted.map(a => (
<VideoTile ev={a} key={a.id} showStatus={false} />
<VideoTile ev={a} key={a.id} showStatus={false} style="grid" />
))}
</VideoGrid>
</div>

View File

@ -20,6 +20,7 @@
"1LBny5": "Stopped",
"1q4BO/": "Not a valid URL",
"1qsXCO": "eg. name@wallet.com",
"1y8vnu": "{m}s",
"2/2yg+": "Add",
"2lVQYF": "...more",
"2ukA4d": "{n} hours",
@ -57,6 +58,7 @@
"8xVdjn": "Video Codec",
"9WRlF4": "Send",
"9a9+ww": "Title",
"9bAnRw": "{m}d",
"9pMqYs": "Nostr Address",
"9rmSgv": "OBS (Open Broadcaster Software) is a free and open source software for video recording and live streaming on Windows, Mac and Linux. It is a popular choice with streamers. You'll need to install this to capture your video, audio and anything else you'd like to add to your stream. Once installed and configured to preference, add your Stream URL and Stream Key from the Stream settings to OBS to form a connection with zap.stream.",
"A1zT+z": "Search results: {term}",
@ -153,6 +155,7 @@
"S39ba6": "What is OBS?",
"SC2nJT": "Audio Codec",
"TDUfVk": "Started",
"TNFpMZ": "{m}h",
"TP/cMX": "Ended",
"TwyMau": "Account",
"UGFYV8": "Welcome to zap.stream!",
@ -165,8 +168,10 @@
"W7DNWx": "Stream Forwarding",
"W9355R": "Unmute",
"WVJZ0U": "Value",
"Wp4l7+": "More Videos",
"WsjXrZ": "Click on Log In",
"X2PZ7D": "Create Goal",
"XD/ATb": "{m}mo",
"XIvYvF": "Failed to get invoice",
"XMGfiA": "Recent Clips",
"XgWvGA": "Reactions",
@ -219,6 +224,7 @@
"j/jueq": "Raiding {name}",
"jJLRgo": "Publish Clip",
"jgOqxt": "Widgets",
"jhTFev": "{m}w",
"jkAQj5": "Stream Ended",
"jr4+vD": "Markdown",
"jvo0vs": "Save",
@ -244,6 +250,7 @@
"pxF+t0": "Popular",
"q+zTWM": "<s>{person}</s> zapped <s>{amount}</s> sats",
"q9ryv4": "Cover image URL (optional)",
"qRV9H5": "{m}h",
"qx6bv2": "Stream Goal (optional)",
"r2Jjms": "Log In",
"rJqhFR": "Stream Setup",
@ -264,6 +271,7 @@
"u6uD94": "Create an Account",
"uTonxS": "Avatar upload fialed",
"uYw2LD": "Stream",
"uksRSi": "Latest Videos",
"vrTOHJ": "{amount} sats",
"w0Xm2F": "Start typing",
"w3btjR": "Gambling",

View File

@ -113,6 +113,7 @@ export interface StreamInfo {
host?: string;
gameId?: string;
gameInfo?: GameInfo;
duration?: number;
}
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
@ -143,6 +144,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "starts", v => (ret.starts = v));
matchTag(t, "ends", v => (ret.ends = v));
matchTag(t, "service", v => (ret.service = v));
matchTag(t, "duration", v => (ret.duration = Number(v)));
}
const { regularTags, prefixedTags } = sortStreamTags(ev?.tags ?? []);
ret.tags = regularTags;

View File

@ -2273,7 +2273,7 @@ __metadata:
languageName: node
linkType: hard
"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:~1.4.0":
"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0":
version: 1.4.0
resolution: "@noble/hashes@npm:1.4.0"
checksum: 10c0/8c3f005ee72e7b8f9cff756dfae1241485187254e3f743873e22073d63906863df5d4f13d441b7530ea614b7a093f0d889309f28b59850f33b66cb26a779a4a5
@ -6037,6 +6037,13 @@ __metadata:
languageName: node
linkType: hard
"media-chrome@npm:^3.2.2":
version: 3.2.2
resolution: "media-chrome@npm:3.2.2"
checksum: 10c0/d3e36629aaf67eae12837be06a874ba1d56c5b6094c26baa8b76b4e420618564447491fd05b28e593306d2025ffd171db6598bab308255dcd69024e9b70c33b6
languageName: node
linkType: hard
"merge2@npm:^1.3.0, merge2@npm:^1.4.1":
version: 1.4.1
resolution: "merge2@npm:1.4.1"
@ -7644,6 +7651,7 @@ __metadata:
"@emoji-mart/react": "npm:^1.1.1"
"@formatjs/cli": "npm:^6.1.3"
"@noble/curves": "npm:^1.4.0"
"@noble/hashes": "npm:^1.4.0"
"@scure/base": "npm:^1.1.6"
"@snort/shared": "npm:^1.0.15"
"@snort/system": "npm:^1.3.2"
@ -7674,6 +7682,7 @@ __metadata:
flag-icons: "npm:^7.2.1"
hls.js: "npm:^1.5.8"
marked: "npm:^12.0.2"
media-chrome: "npm:^3.2.2"
postcss: "npm:^8.4.38"
prettier: "npm:^3.2.5"
prop-types: "npm:^15.8.1"