mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-18 16:41:20 +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 RelayView from "./views/relays/relay";
|
||||||
import RelayReviewsView from "./views/relays/reviews";
|
import RelayReviewsView from "./views/relays/reviews";
|
||||||
import PopularRelaysView from "./views/relays/popular";
|
import PopularRelaysView from "./views/relays/popular";
|
||||||
|
import UserTracksTab from "./views/user/tracks";
|
||||||
|
|
||||||
const ToolsHomeView = React.lazy(() => import("./views/tools"));
|
const ToolsHomeView = React.lazy(() => import("./views/tools"));
|
||||||
const NetworkView = React.lazy(() => import("./views/tools/network"));
|
const NetworkView = React.lazy(() => import("./views/tools/network"));
|
||||||
@ -163,6 +164,7 @@ const router = createHashRouter([
|
|||||||
{ path: "notes", element: <UserNotesTab /> },
|
{ path: "notes", element: <UserNotesTab /> },
|
||||||
{ path: "articles", element: <UserArticlesTab /> },
|
{ path: "articles", element: <UserArticlesTab /> },
|
||||||
{ path: "streams", element: <UserStreamsTab /> },
|
{ path: "streams", element: <UserStreamsTab /> },
|
||||||
|
{ path: "tracks", element: <UserTracksTab /> },
|
||||||
{ path: "zaps", element: <UserZapsTab /> },
|
{ path: "zaps", element: <UserZapsTab /> },
|
||||||
{ path: "likes", element: <UserReactionsTab /> },
|
{ path: "likes", element: <UserReactionsTab /> },
|
||||||
{ path: "lists", element: <UserListsTab /> },
|
{ path: "lists", element: <UserListsTab /> },
|
||||||
|
@ -10,7 +10,7 @@ import { UserLink } from "../../user-link";
|
|||||||
import ListFeedButton from "../../../views/lists/components/list-feed-button";
|
import ListFeedButton from "../../../views/lists/components/list-feed-button";
|
||||||
import { ListCardContent } from "../../../views/lists/components/list-card";
|
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);
|
const link = isSpecialListKind(list.kind) ? createCoordinate(list.kind, list.pubkey) : getSharableEventAddress(list);
|
||||||
|
|
||||||
return (
|
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 EmbeddedStreamMessage from "./event-types/embedded-stream-message";
|
||||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||||
import EmbeddedCommunity from "./event-types/embedded-community";
|
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 = {
|
export type EmbedProps = {
|
||||||
goalProps?: EmbeddedGoalOptions;
|
goalProps?: EmbeddedGoalOptions;
|
||||||
@ -53,6 +55,8 @@ export function EmbedEvent({
|
|||||||
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
||||||
case COMMUNITY_DEFINITION_KIND:
|
case COMMUNITY_DEFINITION_KIND:
|
||||||
return <EmbeddedCommunity community={event} {...cardProps} />;
|
return <EmbeddedCommunity community={event} {...cardProps} />;
|
||||||
|
case STEMSTR_TRACK_KIND:
|
||||||
|
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <EmbeddedUnknown event={event} {...cardProps} />;
|
return <EmbeddedUnknown event={event} {...cardProps} />;
|
||||||
|
@ -59,6 +59,7 @@ import Plus from "./icons/plus";
|
|||||||
import Bookmark from "./icons/bookmark";
|
import Bookmark from "./icons/bookmark";
|
||||||
import BankNote01 from "./icons/bank-note-01";
|
import BankNote01 from "./icons/bank-note-01";
|
||||||
import Wallet02 from "./icons/wallet-02";
|
import Wallet02 from "./icons/wallet-02";
|
||||||
|
import Download01 from "./icons/download-01";
|
||||||
|
|
||||||
const defaultProps: IconProps = { boxSize: 4 };
|
const defaultProps: IconProps = { boxSize: 4 };
|
||||||
|
|
||||||
@ -226,3 +227,4 @@ export const GhostIcon = createIcon({
|
|||||||
|
|
||||||
export const ECashIcon = BankNote01;
|
export const ECashIcon = BankNote01;
|
||||||
export const WalletIcon = Wallet02;
|
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" />
|
<RelaySelectionButton ml="auto" />
|
||||||
</Flex>
|
</Flex>
|
||||||
<IntersectionObserverProvider callback={callback}>
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<Heading size="lg" mt="2">
|
||||||
|
Live
|
||||||
|
</Heading>
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||||
{liveStreams.map((stream) => (
|
{liveStreams.map((stream) => (
|
||||||
<StreamCard key={stream.event.id} stream={stream} />
|
<StreamCard key={stream.event.id} stream={stream} />
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Heading>Ended</Heading>
|
<Heading size="lg" mt="4">
|
||||||
<Divider />
|
Ended
|
||||||
|
</Heading>
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||||
{endedStreams.map((stream) => (
|
{endedStreams.map((stream) => (
|
||||||
<StreamCard key={stream.event.id} stream={stream} />
|
<StreamCard key={stream.event.id} stream={stream} />
|
||||||
|
@ -56,6 +56,7 @@ const tabs = [
|
|||||||
{ label: "Likes", path: "likes" },
|
{ label: "Likes", path: "likes" },
|
||||||
{ label: "Relays", path: "relays" },
|
{ label: "Relays", path: "relays" },
|
||||||
{ label: "Goals", path: "goals" },
|
{ label: "Goals", path: "goals" },
|
||||||
|
{ label: "Tracks", path: "tracks" },
|
||||||
{ label: "Emoji Packs", path: "emojis" },
|
{ label: "Emoji Packs", path: "emojis" },
|
||||||
{ label: "Reports", path: "reports" },
|
{ label: "Reports", path: "reports" },
|
||||||
{ label: "Followers", path: "followers" },
|
{ 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