diff --git a/.changeset/green-jobs-kiss.md b/.changeset/green-jobs-kiss.md new file mode 100644 index 000000000..98f03ddc8 --- /dev/null +++ b/.changeset/green-jobs-kiss.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add track view for stemstr tracks diff --git a/src/app.tsx b/src/app.tsx index 562e914fc..898434766 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: , }, + { + path: "tracks", + element: , + }, { path: "l/:link", element: }, { path: "t/:hashtag", element: }, { diff --git a/src/components/embed-event/event-types/embedded-stemstr-track.tsx b/src/components/embed-event/event-types/embedded-stemstr-track.tsx index d6cf651d6..fa696cae2 100644 --- a/src/components/embed-event/event-types/embedded-stemstr-track.tsx +++ b/src/components/embed-event/event-types/embedded-stemstr-track.tsx @@ -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 & { track: NostrEvent }) { - const streamUrl = getStreamURL(track); - const downloadUrl = getDownloadURL(track); - - let player: ReactNode | null = null; - if (streamUrl) { - player = ; - } else if (downloadUrl) { - player = ( - - - - ); - } - const hashtags = getHashtags(track); return ( @@ -53,7 +36,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit - {player} + {hashtags.length > 0 && ( @@ -74,26 +57,8 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit - {downloadUrl && ( - } - aria-label="Download" - title="Download" - href={downloadUrl.url} - download - isExternal - /> - )} - + + diff --git a/src/components/embed-types/music.tsx b/src/components/embed-types/music.tsx index 70b1b720c..9628bdedd 100644 --- a/src/components/embed-types/music.tsx +++ b/src/components/embed-types/music.tsx @@ -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 ; + return ; } export function renderSoundCloudUrl(match: URL) { diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 45e960baf..03cce43bd 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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; diff --git a/src/components/layout/nav-items.tsx b/src/components/layout/nav-items.tsx index 1b2077e90..38bb10797 100644 --- a/src/components/layout/nav-items.tsx +++ b/src/components/layout/nav-items.tsx @@ -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"; diff --git a/src/helpers/nostr/stemstr.ts b/src/helpers/nostr/stemstr.ts index 6f97ce7f8..0287578a2 100644 --- a/src/helpers/nostr/stemstr.ts +++ b/src/helpers/nostr/stemstr.ts @@ -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) { diff --git a/src/views/other-stuff/apps.ts b/src/views/other-stuff/apps.ts index d80509a79..39b479b83 100644 --- a/src/views/other-stuff/apps.ts +++ b/src/views/other-stuff/apps.ts @@ -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" }, ]; diff --git a/src/views/tracks/components/track-card.tsx b/src/views/tracks/components/track-card.tsx new file mode 100644 index 000000000..4a5a9c359 --- /dev/null +++ b/src/views/tracks/components/track-card.tsx @@ -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) { + const hashtags = getHashtags(track); + + const ref = useRef(null); + useRegisterIntersectionEntity(ref, getEventUID(track)); + + return ( + + + + + + + + + + + {hashtags.length > 0 && ( + + {hashtags.map((hashtag) => ( + #{hashtag} + ))} + + )} + + + + + + + + + + + + + + + ); +} diff --git a/src/views/tracks/components/track-download-button.tsx b/src/views/tracks/components/track-download-button.tsx new file mode 100644 index 000000000..2100e16b2 --- /dev/null +++ b/src/views/tracks/components/track-download-button.tsx @@ -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) { + const download = getDownloadURL(track); + + return download ? ( + } + aria-label="Download" + title="Download" + href={download.url} + download + isExternal + {...props} + /> + ) : null; +} diff --git a/src/views/tracks/components/track-menu.tsx b/src/views/tracks/components/track-menu.tsx new file mode 100644 index 000000000..adbee1fab --- /dev/null +++ b/src/views/tracks/components/track-menu.tsx @@ -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) { + const debugModal = useDisclosure(); + + return ( + <> + + + + + + + }> + View Raw + + + + {debugModal.isOpen && ( + + )} + + ); +} diff --git a/src/views/tracks/components/track-player.tsx b/src/views/tracks/components/track-player.tsx new file mode 100644 index 000000000..b7354cf7d --- /dev/null +++ b/src/views/tracks/components/track-player.tsx @@ -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 ( + + } onClick={showPlayer.onOpen} size="md" variant="outline" /> + {waveform?.map((v) => )} + + ); + + if (streamUrl) { + return ; + } else if (downloadUrl) { + return ( + + + + ); + } else return null; +} diff --git a/src/views/tracks/components/track-stemstr-button.tsx b/src/views/tracks/components/track-stemstr-button.tsx new file mode 100644 index 000000000..94ef24dc6 --- /dev/null +++ b/src/views/tracks/components/track-stemstr-button.tsx @@ -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 ( + + ); +} diff --git a/src/views/tracks/index.tsx b/src/views/tracks/index.tsx new file mode 100644 index 000000000..5f47d9d31 --- /dev/null +++ b/src/views/tracks/index.tsx @@ -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 ( + + + + + + {tracks.map((track) => ( + + ))} + + + ); +} + +export default function TracksView() { + return ( + + + + + + ); +}