feat: shorts
This commit is contained in:
parent
4ed2242655
commit
39f6df907d
@ -12,6 +12,7 @@ export const CARD = 37_777 as EventKind;
|
||||
export const MUTED = 10_000 as EventKind;
|
||||
|
||||
export const VIDEO_KIND = 34_235 as EventKind;
|
||||
export const SHORTS_KIND = 34_236 as EventKind;
|
||||
|
||||
export const MINUTE = 60;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
|
@ -11,7 +11,7 @@ import { Emoji as EmojiComponent } from "../emoji";
|
||||
import { Profile } from "../profile";
|
||||
import { Text } from "../text";
|
||||
import { useMute } from "../mute-button";
|
||||
import { SendZaps, SendZapsDialog } from "../send-zap";
|
||||
import { SendZaps } from "../send-zap";
|
||||
import { CollapsibleEvent } from "../collapsible";
|
||||
|
||||
import { useLogin } from "@/hooks/login";
|
||||
@ -174,12 +174,14 @@ export function ChatMessage({
|
||||
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
|
||||
}}>
|
||||
{zapTarget && <IconButton
|
||||
iconName="zap"
|
||||
iconSize={14}
|
||||
className="p-2 rounded-full bg-layer-2 aspect-square"
|
||||
onClick={() => setZapping(true)}
|
||||
/>}
|
||||
{zapTarget && (
|
||||
<IconButton
|
||||
iconName="zap"
|
||||
iconSize={14}
|
||||
className="p-2 rounded-full bg-layer-2 aspect-square"
|
||||
onClick={() => setZapping(true)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={pickEmoji}
|
||||
iconName="face"
|
||||
@ -196,15 +198,17 @@ export function ChatMessage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zapping && zapTarget && <Modal id="send-zaps" onClose={() => setZapping(false)}>
|
||||
<SendZaps
|
||||
lnurl={zapTarget}
|
||||
eTag={ev.id}
|
||||
pubkey={ev.pubkey}
|
||||
targetName={profile?.name || ev.pubkey}
|
||||
onFinish={() => setZapping(false)}
|
||||
/>
|
||||
</Modal>}
|
||||
{zapping && zapTarget && (
|
||||
<Modal id="send-zaps" onClose={() => setZapping(false)}>
|
||||
<SendZaps
|
||||
lnurl={zapTarget}
|
||||
eTag={ev.id}
|
||||
pubkey={ev.pubkey}
|
||||
targetName={profile?.name || ev.pubkey}
|
||||
onFinish={() => setZapping(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
{showEmojiPicker && (
|
||||
<Suspense>
|
||||
|
@ -6,8 +6,19 @@ import { useLogin } from "@/hooks/login";
|
||||
import AsyncButton from "./async-button";
|
||||
import { useContext } from "react";
|
||||
import { ZapEvent } from "./send-zap";
|
||||
import classNames from "classnames";
|
||||
|
||||
export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent; replyKind?: EventKind }) {
|
||||
export default function EventReactions({
|
||||
ev,
|
||||
replyKind,
|
||||
vertical,
|
||||
className,
|
||||
}: {
|
||||
ev: TaggedNostrEvent;
|
||||
replyKind?: EventKind;
|
||||
vertical?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const link = NostrLink.fromEvent(ev)!;
|
||||
const login = useLogin();
|
||||
const system = useContext(SnortContext);
|
||||
@ -36,49 +47,61 @@ export default function EventReactions({ ev, replyKind }: { ev: TaggedNostrEvent
|
||||
|
||||
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";
|
||||
const iconClass = classNames(
|
||||
"flex gap-1 items-center tabular-nums cursor-pointer select-none hover:text-primary transition",
|
||||
{
|
||||
"flex-col": vertical,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 mt-2 items-center text-neutral-500">
|
||||
<div
|
||||
className={classNames(className, "grid items-center text-neutral-500", {
|
||||
"flex-col gap-8": vertical,
|
||||
"gap-4": !vertical,
|
||||
})}>
|
||||
{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}
|
||||
<div className={iconClass}>
|
||||
<Icon name="message-circle" />
|
||||
{grouped.replies.length > 0 ? grouped.replies.length : <> </>}
|
||||
</div>
|
||||
</AsyncButton>
|
||||
)}
|
||||
<ZapEvent ev={ev}>
|
||||
<div className={iconClass}>
|
||||
{reactedIcon("zap", "zap-filled", "text-zap", grouped.zaps, EventKind.ZapReceipt)}
|
||||
{totalZaps > 0 ? formatSats(totalZaps) : undefined}
|
||||
{totalZaps > 0 ? formatSats(totalZaps) : <> </>}
|
||||
</div>
|
||||
</ZapEvent>
|
||||
<AsyncButton
|
||||
className={iconClass}
|
||||
onClick={async () => {
|
||||
if (pub) {
|
||||
const evReact = await pub.react(ev);
|
||||
await system.BroadcastEvent(evReact);
|
||||
}
|
||||
}}>
|
||||
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
|
||||
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : undefined}
|
||||
<div className={iconClass}>
|
||||
{reactedIcon("heart", "heart-solid", "text-red-500", grouped.reactions.positive, EventKind.Reaction)}
|
||||
{grouped.reactions.positive.length > 0 ? grouped.reactions.positive.length : <> </>}
|
||||
</div>
|
||||
</AsyncButton>
|
||||
<AsyncButton
|
||||
className={iconClass}
|
||||
onClick={async () => {
|
||||
if (pub) {
|
||||
const evReact = await pub.repost(ev);
|
||||
await system.BroadcastEvent(evReact);
|
||||
}
|
||||
}}>
|
||||
<Icon name="repost" />
|
||||
{grouped.reposts.length > 0 ? grouped.reposts.length : undefined}
|
||||
<div className={iconClass}>
|
||||
<Icon name="repost" />
|
||||
{grouped.reposts.length > 0 ? grouped.reposts.length : <> </>}
|
||||
</div>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
);
|
||||
|
74
src/element/video-info.tsx
Normal file
74
src/element/video-info.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { FollowButton } from "./follow-button";
|
||||
import { Profile, getName } from "./profile";
|
||||
import { SendZapsDialog } from "./send-zap";
|
||||
import { ShareMenu } from "./share-menu";
|
||||
import { useVideoPlayerContext } from "./video/context";
|
||||
import { getHost } from "@/utils";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { StreamSummary } from "./stream/summary";
|
||||
import classNames from "classnames";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { WriteMessage } from "./chat/write-message";
|
||||
import VideoComments from "./video/comments";
|
||||
|
||||
export function VideoInfo({
|
||||
showComments,
|
||||
showShare,
|
||||
showZap,
|
||||
}: {
|
||||
showComments?: boolean;
|
||||
showShare?: boolean;
|
||||
showZap?: boolean;
|
||||
}) {
|
||||
const ctx = useVideoPlayerContext();
|
||||
const ev = ctx.event;
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const host = getHost(ctx.event);
|
||||
const profile = useUserProfile(host);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("row-start-2 col-start-1 max-xl:px-4 flex flex-col gap-4", {
|
||||
"mx-auto w-[40dvw]": ctx.widePlayer,
|
||||
})}>
|
||||
<div className="font-medium text-xl">{ctx.video?.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 && (
|
||||
<>
|
||||
{(showShare ?? true) && <ShareMenu ev={ev} />}
|
||||
{(showZap ?? true) && zapTarget && (
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
pubkey={host}
|
||||
aTag={link.tagKey}
|
||||
targetName={getName(ev.pubkey, profile)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ctx.video?.summary && <StreamSummary text={ctx.video.summary} />}
|
||||
{(showComments ?? true) && (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Comments" />
|
||||
</h3>
|
||||
<div>
|
||||
<WriteMessage link={link} emojiPacks={[]} kind={1} />
|
||||
</div>
|
||||
<VideoComments link={link} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import { VideoInfo } from "@/service/video/info";
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface VideoPlayerContext {
|
||||
video?: VideoInfo;
|
||||
video: VideoInfo;
|
||||
event: TaggedNostrEvent;
|
||||
widePlayer: boolean;
|
||||
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => void;
|
||||
}
|
||||
@ -10,15 +12,17 @@ interface VideoPlayerContext {
|
||||
const VPContext = createContext<VideoPlayerContext>({
|
||||
widePlayer: false,
|
||||
update: () => {},
|
||||
});
|
||||
} as unknown as VideoPlayerContext);
|
||||
|
||||
export function useVideoPlayerContext() {
|
||||
return useContext(VPContext);
|
||||
}
|
||||
|
||||
export function VideoPlayerContextProvider({ info, children }: { info: VideoInfo; children?: ReactNode }) {
|
||||
export function VideoPlayerContextProvider({ event, children }: { event: TaggedNostrEvent; children?: ReactNode }) {
|
||||
const info = VideoInfo.parse(event);
|
||||
const [state, setState] = useState<VideoPlayerContext>({
|
||||
video: info,
|
||||
event,
|
||||
widePlayer: localStorage.getItem("wide-player") === "true",
|
||||
update: (fn: (c: VideoPlayerContext) => VideoPlayerContext) => {
|
||||
setState(fn);
|
||||
|
@ -15,7 +15,15 @@ import useImgProxy from "@/hooks/img-proxy";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { useVideoPlayerContext } from "./context";
|
||||
|
||||
export default function VideoPlayer() {
|
||||
export default function VideoPlayer({
|
||||
showPip,
|
||||
showWideMode,
|
||||
loop,
|
||||
}: {
|
||||
showPip?: boolean;
|
||||
showWideMode?: boolean;
|
||||
loop?: boolean;
|
||||
}) {
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
const ctx = useVideoPlayerContext();
|
||||
|
||||
@ -23,10 +31,11 @@ export default function VideoPlayer() {
|
||||
return (
|
||||
<MediaController className="min-w-0 w-full" mediaStreamType="on-demand">
|
||||
<video
|
||||
className="max-h-[80dvh] aspect-video"
|
||||
className="max-h-[80dvh]"
|
||||
slot="media"
|
||||
autoPlay={true}
|
||||
controls={false}
|
||||
loop={loop}
|
||||
poster={proxy(ctx.video?.bestPoster()?.url ?? "")}>
|
||||
{ctx.video?.sources().map(a => <source src={a.url} type={a.mimeType} />)}
|
||||
</video>
|
||||
@ -37,9 +46,9 @@ export default function VideoPlayer() {
|
||||
<MediaTimeDisplay showDuration></MediaTimeDisplay>
|
||||
<MediaMuteButton />
|
||||
<MediaVolumeRange />
|
||||
<MediaPipButton />
|
||||
{(showPip ?? true) && <MediaPipButton />}
|
||||
<MediaFullscreenButton />
|
||||
{isDesktop && (
|
||||
{isDesktop && (showWideMode ?? true) && (
|
||||
<MediaPlayerSizeButtonReact
|
||||
onClick={() =>
|
||||
ctx.update(c => ({
|
||||
|
@ -736,6 +736,9 @@
|
||||
"kAEQyV": {
|
||||
"defaultMessage": "OK"
|
||||
},
|
||||
"kGjqZ4": {
|
||||
"defaultMessage": "Latest Shorts"
|
||||
},
|
||||
"kc5EOy": {
|
||||
"defaultMessage": "Username is too long"
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { SHORTS_KIND, VIDEO_KIND } from "@/const";
|
||||
import { useStreamLink } from "@/hooks/stream-link";
|
||||
import { getEventFromLocationState } from "@/utils";
|
||||
import { NostrPrefix, EventKind } from "@snort/system";
|
||||
@ -9,6 +9,7 @@ import { EventEmbed as NostrEventElement } from "@/element/event-embed";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useLayout } from "./layout/context";
|
||||
import classNames from "classnames";
|
||||
import { ShortPage } from "./short";
|
||||
|
||||
export function LinkHandler() {
|
||||
const location = useLocation();
|
||||
@ -32,6 +33,8 @@ export function LinkHandler() {
|
||||
);
|
||||
} else if (link.kind === VIDEO_KIND) {
|
||||
return <VideoPage link={link} evPreload={evPreload} />;
|
||||
} else if (link.kind === SHORTS_KIND) {
|
||||
return <ShortPage link={link} evPreload={evPreload} />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
|
25
src/pages/short.tsx
Normal file
25
src/pages/short.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import EventReactions from "@/element/event-reactions";
|
||||
import { VideoInfo } from "@/element/video-info";
|
||||
import { VideoPlayerContextProvider } from "@/element/video/context";
|
||||
import VideoPlayer from "@/element/video/player";
|
||||
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
export function ShortPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
|
||||
if (!ev) return;
|
||||
return (
|
||||
<VideoPlayerContextProvider event={ev}>
|
||||
<div className="max-xl:py-2 max-xl:w-full xl:w-[550px] mx-auto">
|
||||
<div className="relative">
|
||||
<VideoPlayer showPip={false} showWideMode={false} loop={true} />
|
||||
<div className="absolute bottom-0 -right-14">
|
||||
<EventReactions ev={ev} vertical={true} replyKind={1} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<VideoInfo showComments={false} showZap={false} />
|
||||
</div>
|
||||
</VideoPlayerContextProvider>
|
||||
);
|
||||
}
|
@ -1,3 +1,34 @@
|
||||
import { SHORTS_KIND } from "@/const";
|
||||
import VideoGrid from "@/element/video-grid";
|
||||
import { VideoTile } from "@/element/video/video-tile";
|
||||
import { findTag } from "@/utils";
|
||||
import { RequestBuilder } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export function ShortsPage() {
|
||||
return <>Coming soon...</>;
|
||||
const rb = new RequestBuilder("shorts");
|
||||
rb.withFilter().kinds([SHORTS_KIND]);
|
||||
|
||||
const videos = useRequestBuilder(rb);
|
||||
|
||||
const sorted = videos.sort((a, b) => {
|
||||
const pubA = findTag(a, "published_at");
|
||||
const pubB = findTag(b, "published_at");
|
||||
return Number(pubA) > Number(pubB) ? -1 : 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Latest Shorts" />
|
||||
</h2>
|
||||
<br />
|
||||
<VideoGrid>
|
||||
{sorted.map(a => (
|
||||
<VideoTile ev={a} key={a.id} style="grid" />
|
||||
))}
|
||||
</VideoGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,32 +1,23 @@
|
||||
import { WriteMessage } from "@/element/chat/write-message";
|
||||
import { FollowButton } from "@/element/follow-button";
|
||||
import { Profile, getName } from "@/element/profile";
|
||||
import { SendZapsDialog } from "@/element/send-zap";
|
||||
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, findTag } from "@/utils";
|
||||
import { NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
|
||||
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { StreamTile } from "@/element/stream/stream-tile";
|
||||
import { VIDEO_KIND } from "@/const";
|
||||
import { VideoInfo } from "@/service/video/info";
|
||||
import { VideoPlayerContextProvider, useVideoPlayerContext } from "@/element/video/context";
|
||||
import VideoPlayer from "@/element/video/player";
|
||||
import { VideoInfo } from "@/element/video-info";
|
||||
|
||||
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
|
||||
if (!ev) return;
|
||||
const video = VideoInfo.parse(ev);
|
||||
|
||||
return (
|
||||
<VideoPlayerContextProvider info={video}>
|
||||
<VideoPlayerContextProvider event={ev}>
|
||||
<VideoPageInner ev={ev} />
|
||||
</VideoPlayerContextProvider>
|
||||
);
|
||||
@ -37,9 +28,6 @@ function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const ctx = useVideoPlayerContext();
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
|
||||
const profile = useUserProfile(host);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("xl:p-4 grow xl:grid xl:gap-2 xl:grid-cols-[auto_450px]", {
|
||||
@ -52,43 +40,7 @@ function VideoPageInner({ ev }: { ev: TaggedNostrEvent }) {
|
||||
<VideoPlayer />
|
||||
</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]": ctx.widePlayer,
|
||||
})}>
|
||||
<div className="font-medium text-xl">{ctx.video?.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>
|
||||
{ctx.video?.summary && <StreamSummary text={ctx.video.summary} />}
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Comments" />
|
||||
</h3>
|
||||
<div>
|
||||
<WriteMessage link={link} emojiPacks={[]} kind={1} />
|
||||
</div>
|
||||
<VideoComments link={link} />
|
||||
</div>
|
||||
<VideoInfo showComments={true} />
|
||||
<div
|
||||
className={classNames("p-2 col-start-2", {
|
||||
"row-start-1 row-span-3": !ctx.widePlayer,
|
||||
|
@ -242,6 +242,7 @@
|
||||
"jvo0vs": "Save",
|
||||
"k21gTS": "e.g. about me",
|
||||
"kAEQyV": "OK",
|
||||
"kGjqZ4": "Latest Shorts",
|
||||
"kc5EOy": "Username is too long",
|
||||
"khJ51Q": "Stream Earnings",
|
||||
"kp0NPF": "Planned",
|
||||
|
Loading…
x
Reference in New Issue
Block a user