mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-07 03:18:02 +02:00
add stemstr embeds
This commit is contained in:
parent
bfc28c097b
commit
85a9dad33a
5
.changeset/cuddly-carrots-camp.md
Normal file
5
.changeset/cuddly-carrots-camp.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add stemstr embeds
|
@ -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 /> },
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
|
@ -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
|
||||
|
52
src/components/live-audio-player.tsx
Normal file
52
src/components/live-audio-player.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
30
src/helpers/nostr/stemstr.ts
Normal file
30
src/helpers/nostr/stemstr.ts
Normal 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 };
|
||||
}
|
@ -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} />
|
||||
|
@ -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
51
src/views/user/tracks.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user