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
- />
- )}
- }
- href={`https://stemstr.app/thread/${track.id}`}
- colorScheme="purple"
- isExternal
- >
- View on Stemstr
-
+
+
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}
+ ))}
+
+ )}
+
+
+
+ } isDisabled>
+ Comment
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+ }
+ href={`https://stemstr.app/thread/${track.id}`}
+ colorScheme="purple"
+ isExternal
+ >
+ View on Stemstr
+
+ );
+}
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 (
+
+
+
+
+
+ );
+}