add stemstr embeds

This commit is contained in:
hzrd149 2023-10-12 14:27:39 -05:00
parent bfc28c097b
commit 85a9dad33a
11 changed files with 255 additions and 3 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add stemstr embeds

View File

@ -66,6 +66,7 @@ import RelaysView from "./views/relays";
import RelayView from "./views/relays/relay";
import RelayReviewsView from "./views/relays/reviews";
import PopularRelaysView from "./views/relays/popular";
import UserTracksTab from "./views/user/tracks";
const ToolsHomeView = React.lazy(() => import("./views/tools"));
const NetworkView = React.lazy(() => import("./views/tools/network"));
@ -163,6 +164,7 @@ const router = createHashRouter([
{ path: "notes", element: <UserNotesTab /> },
{ path: "articles", element: <UserArticlesTab /> },
{ path: "streams", element: <UserStreamsTab /> },
{ path: "tracks", element: <UserTracksTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "likes", element: <UserReactionsTab /> },
{ path: "lists", element: <UserListsTab /> },

View File

@ -10,7 +10,7 @@ import { UserLink } from "../../user-link";
import ListFeedButton from "../../../views/lists/components/list-feed-button";
import { ListCardContent } from "../../../views/lists/components/list-card";
export default function EmbeddedList({ list: list, ...props }: Omit<CardProps, "children"> & { list: NostrEvent }) {
export default function EmbeddedList({ list, ...props }: Omit<CardProps, "children"> & { list: NostrEvent }) {
const link = isSpecialListKind(list.kind) ? createCoordinate(list.kind, list.pubkey) : getSharableEventAddress(list);
return (

View File

@ -0,0 +1,101 @@
import {
Box,
Button,
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
IconButton,
Image,
Link,
Tag,
Tooltip,
} from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { UserAvatarLink } from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import { InlineNoteContent } from "../../note/inline-note-content";
import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr";
import { DownloadIcon, ReplyIcon } from "../../icons";
import NoteZapButton from "../../note/note-zap-button";
import { QuoteRepostButton } from "../../note/components/quote-repost-button";
import Timestamp from "../../timestamp";
import { ReactNode } from "react";
import { LiveAudioPlayer } from "../../live-audio-player";
// example nevent1qqst32cnyhhs7jt578u7vp3y047dduuwjquztpvwqc43f3nvg8dh28gpzamhxue69uhhyetvv9ujuum5v4khxarj9eshquq4rxdxa
export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps, "children"> & { track: NostrEvent }) {
const streamUrl = getStreamURL(track);
const downloadUrl = getDownloadURL(track);
let player: ReactNode | null = null;
if (streamUrl) {
player = <LiveAudioPlayer stream={streamUrl.url} w="full" />;
} else if (downloadUrl) {
player = (
<Box as="audio" controls w="full">
<source src={downloadUrl.url} type={downloadUrl.format} />
</Box>
);
}
const hashtags = getHashtags(track);
return (
<Card {...props}>
<CardHeader display="flex" alignItems="center" p="2" pb="0" gap="2">
<UserAvatarLink pubkey={track.pubkey} size="xs" />
<UserLink pubkey={track.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<Timestamp ml="auto" timestamp={track.created_at} />
</CardHeader>
<CardBody p="2" display="flex" gap="2" flexDirection="column">
{player}
<InlineNoteContent event={track} />
{hashtags.length > 0 && (
<Flex wrap="wrap" gap="2">
{hashtags.map((hashtag) => (
<Tag>#{hashtag}</Tag>
))}
</Flex>
)}
</CardBody>
<CardFooter px="2" pt="0" pb="2" flexWrap="wrap" gap="2">
<ButtonGroup size="sm">
<Tooltip label="Coming soon...">
<Button leftIcon={<ReplyIcon />} isDisabled>
Comment
</Button>
</Tooltip>
<QuoteRepostButton event={track} />
<NoteZapButton event={track} />
</ButtonGroup>
<ButtonGroup size="sm" ml="auto">
{downloadUrl && (
<IconButton
as={Link}
icon={<DownloadIcon />}
aria-label="Download"
title="Download"
href={downloadUrl.url}
download
isExternal
/>
)}
<Button
as={Link}
leftIcon={<Image src="https://stemstr.app/favicon.svg" />}
href={`https://stemstr.app/thread/${track.id}`}
colorScheme="purple"
isExternal
>
View on Stemstr
</Button>
</ButtonGroup>
</CardFooter>
</Card>
);
}

View File

@ -23,6 +23,8 @@ import EmbeddedBadge from "./event-types/embedded-badge";
import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
import EmbeddedCommunity from "./event-types/embedded-community";
import { STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
import EmbeddedStemstrTrack from "./event-types/embedded-stemstr-track";
export type EmbedProps = {
goalProps?: EmbeddedGoalOptions;
@ -53,6 +55,8 @@ export function EmbedEvent({
return <EmbeddedStreamMessage message={event} {...cardProps} />;
case COMMUNITY_DEFINITION_KIND:
return <EmbeddedCommunity community={event} {...cardProps} />;
case STEMSTR_TRACK_KIND:
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
}
return <EmbeddedUnknown event={event} {...cardProps} />;

View File

@ -59,6 +59,7 @@ import Plus from "./icons/plus";
import Bookmark from "./icons/bookmark";
import BankNote01 from "./icons/bank-note-01";
import Wallet02 from "./icons/wallet-02";
import Download01 from "./icons/download-01";
const defaultProps: IconProps = { boxSize: 4 };
@ -226,3 +227,4 @@ export const GhostIcon = createIcon({
export const ECashIcon = BankNote01;
export const WalletIcon = Wallet02;
export const DownloadIcon = Download01

View File

@ -0,0 +1,52 @@
import { HTMLProps, useEffect, useRef } from "react";
import { Box, BoxProps } from "@chakra-ui/react";
import Hls from "hls.js";
export function LiveAudioPlayer({
stream,
autoPlay,
poster,
muted,
...props
}: Omit<BoxProps, "children"> & {
stream?: string;
autoPlay?: boolean;
poster?: string;
muted?: HTMLProps<HTMLVideoElement>["muted"];
}) {
const audio = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (stream && audio.current && !audio.current.src && Hls.isSupported()) {
try {
const hls = new Hls({ capLevelToPlayerSize: true });
hls.loadSource(stream);
hls.attachMedia(audio.current);
hls.on(Hls.Events.ERROR, (event, data) => {
const errorType = data.type;
if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) {
hls.stopLoad();
hls.detachMedia();
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {});
return () => hls.destroy();
} catch (e) {
console.error(e);
}
}
}, [audio, stream]);
return (
<Box
as="audio"
ref={audio}
playsInline={true}
autoPlay={autoPlay}
poster={poster}
muted={muted}
controls
{...props}
/>
);
}

View File

@ -0,0 +1,30 @@
import { NostrEvent } from "../../types/nostr-event";
export const STEMSTR_TRACK_KIND = 1808;
export function getSha256Hash(track: NostrEvent) {
return track.tags.find((t) => t[0] === "x")?.[1];
}
export function getWaveform(track: NostrEvent) {
const tag = track.tags.find((t) => t[0] === "waveform");
if (tag?.[1]) return JSON.parse(tag[1]) as number[];
}
export function getHashtags(track: NostrEvent) {
return track.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string);
}
export function getDownloadURL(track: NostrEvent) {
const tag = track.tags.find((t) => t[0] === "download_url");
if (!tag) return;
const url = tag[1];
if (!url) throw new Error("missing download url");
const format = tag[2];
return { url, format };
}
export function getStreamURL(track: NostrEvent) {
const tag = track.tags.find((t) => t[0] === "stream_url");
if (!tag) return;
const url = tag[1];
if (!url) throw new Error("missing download url");
const format = tag[2];
return { url, format };
}

View File

@ -61,13 +61,17 @@ function StreamsPage() {
<RelaySelectionButton ml="auto" />
</Flex>
<IntersectionObserverProvider callback={callback}>
<Heading size="lg" mt="2">
Live
</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{liveStreams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} />
))}
</SimpleGrid>
<Heading>Ended</Heading>
<Divider />
<Heading size="lg" mt="4">
Ended
</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
{endedStreams.map((stream) => (
<StreamCard key={stream.event.id} stream={stream} />

View File

@ -56,6 +56,7 @@ const tabs = [
{ label: "Likes", path: "likes" },
{ label: "Relays", path: "relays" },
{ label: "Goals", path: "goals" },
{ label: "Tracks", path: "tracks" },
{ label: "Emoji Packs", path: "emojis" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },

51
src/views/user/tracks.tsx Normal file
View File

@ -0,0 +1,51 @@
import { useRef } from "react";
import { useOutletContext } from "react-router-dom";
import { Box, SimpleGrid } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { getEventUID } from "../../helpers/nostr/events";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
import EmbeddedStemstrTrack from "../../components/embed-event/event-types/embedded-stemstr-track";
import { unique } from "../../helpers/array";
import { NostrEvent } from "../../types/nostr-event";
function Track({ track }: { track: NostrEvent }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(track));
return (
<Box ref={ref}>
<EmbeddedStemstrTrack track={track} />
</Box>
);
}
export default function UserTracksTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(pubkey + "-tracks", unique([...readRelays, "wss://relay.stemstr.app"]), {
authors: [pubkey],
kinds: [STEMSTR_TRACK_KIND],
});
const tracks = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing="4">
{tracks.map((track) => (
<Track key={getEventUID(track)} track={track} />
))}
</SimpleGrid>
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}