Add track view for stemstr tracks

This commit is contained in:
hzrd149 2023-12-29 08:50:40 -06:00
parent 56fc645f16
commit d1af1e1271
14 changed files with 267 additions and 45 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add track view for stemstr tracks

View File

@ -80,6 +80,7 @@ import SatelliteCDNView from "./views/tools/satellite-cdn";
import OtherStuffView from "./views/other-stuff";
import { RouteProviders } from "./providers/route";
import LaunchpadView from "./views/launchpad";
import TracksView from "./views/tracks";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -366,6 +367,10 @@ const router = createHashRouter([
path: "streams",
element: <StreamsView />,
},
{
path: "tracks",
element: <TracksView />,
},
{ path: "l/:link", element: <NostrLinkView /> },
{ path: "t/:hashtag", element: <HashTagView /> },
{

View File

@ -1,6 +1,4 @@
import { ReactNode } from "react";
import {
Box,
Button,
ButtonGroup,
Card,
@ -9,9 +7,6 @@ import {
CardHeader,
CardProps,
Flex,
IconButton,
Image,
Link,
Tag,
Tooltip,
} from "@chakra-ui/react";
@ -20,29 +15,17 @@ import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import UserLink from "../../user-link";
import { CompactNoteContent } from "../../compact-note-content";
import { getDownloadURL, getHashtags, getStreamURL } from "../../../helpers/nostr/stemstr";
import { DownloadIcon, ReplyIcon } from "../../icons";
import { getHashtags } from "../../../helpers/nostr/stemstr";
import { ReplyIcon } from "../../icons";
import NoteZapButton from "../../note/note-zap-button";
import QuoteRepostButton from "../../note/components/quote-repost-button";
import Timestamp from "../../timestamp";
import { LiveAudioPlayer } from "../../live-audio-player";
import TrackStemstrButton from "../../../views/tracks/components/track-stemstr-button";
import TrackDownloadButton from "../../../views/tracks/components/track-download-button";
import TrackPlayer from "../../../views/tracks/components/track-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 (
@ -53,7 +36,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
<Timestamp ml="auto" timestamp={track.created_at} />
</CardHeader>
<CardBody p="2" display="flex" gap="2" flexDirection="column">
{player}
<TrackPlayer track={track} />
<CompactNoteContent event={track} />
{hashtags.length > 0 && (
<Flex wrap="wrap" gap="2">
@ -74,26 +57,8 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
<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>
<TrackDownloadButton track={track} />
<TrackStemstrButton track={track} />
</ButtonGroup>
</CardFooter>
</Card>

View File

@ -2,6 +2,7 @@ import { CSSProperties } from "react";
import { Box, useColorMode } from "@chakra-ui/react";
import { EmbedEventPointer } from "../embed-event";
import appSettings from "../../services/settings/app-settings";
import { STEMSTR_RELAY } from "../../helpers/nostr/stemstr";
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
@ -121,7 +122,7 @@ export function renderStemstrUrl(match: URL) {
const [_, base, id] = match.pathname.split("/");
if (base !== "thread" || id.length !== 64) return null;
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: ["wss://relay.stemstr.app"] } }} />;
return <EmbedEventPointer pointer={{ type: "nevent", data: { id, relays: [STEMSTR_RELAY] } }} />;
}
export function renderSoundCloudUrl(match: URL) {

View File

@ -65,6 +65,7 @@ import Translate01 from "./icons/translate-01";
import MessageChatSquare from "./icons/message-chat-square";
import Package from "./icons/package";
import Magnet from "./icons/magnet";
import Recording02 from "./icons/recording-02";
const defaultProps: IconProps = { boxSize: 4 };
@ -237,4 +238,5 @@ export const TranslateIcon = Translate01;
export const ChannelsIcon = MessageChatSquare;
export const ThreadIcon = MessageChatSquare;
export const ThingsIcon = Package;
export const TorrentIcon = Magnet
export const TorrentIcon = Magnet;
export const TrackIcon = Recording02;

View File

@ -79,6 +79,7 @@ export default function NavItems() {
else if (location.pathname.startsWith("/settings")) active = "settings";
else if (location.pathname.startsWith("/tools")) active = "tools";
else if (location.pathname.startsWith("/search")) active = "search";
else if (location.pathname.startsWith("/tracks")) active = "tracks";
else if (location.pathname.startsWith("/t/")) active = "search";
else if (location.pathname.startsWith("/torrents")) active = "tools";
else if (location.pathname.startsWith("/map")) active = "tools";

View File

@ -1,5 +1,6 @@
import { NostrEvent } from "../../types/nostr-event";
export const STEMSTR_RELAY = "wss://relay.stemstr.app";
export const STEMSTR_TRACK_KIND = 1808;
export function getSha256Hash(track: NostrEvent) {

View File

@ -10,6 +10,7 @@ import {
MapIcon,
MuteIcon,
TorrentIcon,
TrackIcon,
} from "../../components/icons";
import { App } from "./component/app-card";
import ShieldOff from "../../components/icons/shield-off";
@ -35,6 +36,7 @@ export const internalApps: App[] = [
{ title: "Torrents", description: "Browse torrents on nostr", icon: TorrentIcon, id: "torrents", to: "/torrents" },
{ title: "Emojis", description: "Create custom emoji packs", icon: EmojiPacksIcon, id: "emojis", to: "/emojis" },
{ title: "Lists", description: "Browse and create lists", icon: ListsIcon, id: "lists", to: "/lists" },
{ title: "Tracks", description: "Browse stemstr tracks", icon: TrackIcon, id: "tracks", to: "/tracks" },
// { title: "Things", icon: ThingsIcon, id: "things", to: "/things" },
];

View File

@ -0,0 +1,62 @@
import { useRef } from "react";
import { Button, ButtonGroup, Card, CardBody, CardFooter, CardHeader, CardProps, Flex, Tag } from "@chakra-ui/react";
import { getEventUID } from "../../../helpers/nostr/events";
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
import { NostrEvent } from "../../../types/nostr-event";
import { getHashtags } from "../../../helpers/nostr/stemstr";
import { CompactNoteContent } from "../../../components/compact-note-content";
import Timestamp from "../../../components/timestamp";
import UserLink from "../../../components/user-link";
import UserAvatarLink from "../../../components/user-avatar-link";
import { ReplyIcon } from "../../../components/icons";
import QuoteRepostButton from "../../../components/note/components/quote-repost-button";
import NoteZapButton from "../../../components/note/note-zap-button";
import TrackStemstrButton from "./track-stemstr-button";
import TrackDownloadButton from "./track-download-button";
import TrackPlayer from "./track-player";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import TrackMenu from "./track-menu";
export default function TrackCard({ track, ...props }: { track: NostrEvent } & Omit<CardProps, "children">) {
const hashtags = getHashtags(track);
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(track));
return (
<Card variant="outline" ref={ref} {...props}>
<CardHeader display="flex" alignItems="center" p="2" pb="0" gap="2">
<UserAvatarLink pubkey={track.pubkey} size="sm" />
<UserLink pubkey={track.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={track.pubkey} onlyIcon />
<Timestamp ml="auto" timestamp={track.created_at} />
</CardHeader>
<CardBody p="2" display="flex" gap="2" flexDirection="column">
<TrackPlayer track={track} />
<CompactNoteContent event={track} />
{hashtags.length > 0 && (
<Flex wrap="wrap" gap="2">
{hashtags.map((hashtag) => (
<Tag key={hashtag}>#{hashtag}</Tag>
))}
</Flex>
)}
</CardBody>
<CardFooter px="2" pt="0" pb="2" flexWrap="wrap" gap="2">
<ButtonGroup size="sm" variant="ghost">
<Button leftIcon={<ReplyIcon />} isDisabled>
Comment
</Button>
<QuoteRepostButton event={track} />
<NoteZapButton event={track} />
</ButtonGroup>
<ButtonGroup size="sm" ml="auto">
<TrackDownloadButton track={track} />
<TrackStemstrButton track={track} />
<TrackMenu track={track} aria-label="Options" />
</ButtonGroup>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,24 @@
import { IconButton, IconButtonProps, Link } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { getDownloadURL } from "../../../helpers/nostr/stemstr";
import { DownloadIcon } from "../../../components/icons";
export default function TrackDownloadButton({
track,
...props
}: { track: NostrEvent } & Omit<IconButtonProps, "aria-label" | "icon">) {
const download = getDownloadURL(track);
return download ? (
<IconButton
as={Link}
icon={<DownloadIcon />}
aria-label="Download"
title="Download"
href={download.url}
download
isExternal
{...props}
/>
) : null;
}

View File

@ -0,0 +1,37 @@
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app";
import CopyShareLinkMenuItem from "../../../components/common-menu-items/copy-share-link";
import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code";
import MuteUserMenuItem from "../../../components/common-menu-items/mute-user";
import { CodeIcon } from "../../../components/icons";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
export default function TrackMenu({
track,
detailsClick,
...props
}: { track: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
const debugModal = useDisclosure();
return (
<>
<CustomMenuIconButton {...props}>
<OpenInAppMenuItem event={track} />
<CopyShareLinkMenuItem event={track} />
<CopyEmbedCodeMenuItem event={track} />
<MuteUserMenuItem event={track} />
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</CustomMenuIconButton>
{debugModal.isOpen && (
<NoteDebugModal event={track} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
)}
</>
);
}

View File

@ -0,0 +1,43 @@
import { Box, Flex, IconButton, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import { LiveAudioPlayer } from "../../../components/live-audio-player";
import { getDownloadURL, getStreamURL, getWaveform } from "../../../helpers/nostr/stemstr";
import Play from "../../../components/icons/play";
export default function TrackPlayer({ track }: { track: NostrEvent }) {
const streamUrl = getStreamURL(track);
const downloadUrl = getDownloadURL(track);
const waveform = getWaveform(track);
const showPlayer = useDisclosure();
if (!showPlayer.isOpen)
return (
<Flex
gap="2px"
h="20"
alignItems="center"
px="4"
py="2"
overflow="hidden"
borderColor="primary.500"
borderWidth="1px"
borderRadius="lg"
position="relative"
>
<IconButton aria-label="Play" mr="4" icon={<Play />} onClick={showPlayer.onOpen} size="md" variant="outline" />
{waveform?.map((v) => <Box h={v + "%"} w="3px" bg="primary.800" />)}
</Flex>
);
if (streamUrl) {
return <LiveAudioPlayer stream={streamUrl.url} w="full" autoPlay h="20" />;
} else if (downloadUrl) {
return (
<Box as="audio" controls w="full" autoPlay h="20">
<source src={downloadUrl.url} type={downloadUrl.format} />
</Box>
);
} else return null;
}

View File

@ -0,0 +1,16 @@
import { Button, Image, Link } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
export default function TrackStemstrButton({ track }: { track: NostrEvent }) {
return (
<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>
);
}

View File

@ -0,0 +1,58 @@
import { useCallback } from "react";
import { Flex } from "@chakra-ui/react";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { STEMSTR_RELAY, STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
import useSubject from "../../hooks/use-subject";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/local/relay-selection-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import TrackCard from "./components/track-card";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import { NostrEvent } from "../../types/nostr-event";
function TracksPage() {
const { listId, filter } = usePeopleListContext();
const { relays } = useRelaySelectionContext();
const clientMuteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (clientMuteFilter(event)) return false;
return true;
},
[clientMuteFilter],
);
const timeline = useTimelineLoader(`${listId}-tracks`, relays, filter && { kinds: [STEMSTR_TRACK_KIND], ...filter }, {
eventFilter,
});
const tracks = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout>
<Flex gap="2" wrap="wrap">
<PeopleListSelection />
</Flex>
<IntersectionObserverProvider callback={callback}>
{tracks.map((track) => (
<TrackCard track={track} />
))}
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}
export default function TracksView() {
return (
<PeopleListProvider>
<RelaySelectionProvider additionalDefaults={[STEMSTR_RELAY]}>
<TracksPage />
</RelaySelectionProvider>
</PeopleListProvider>
);
}