From edc0c7b9995d11fe78604240545a12f417e391c1 Mon Sep 17 00:00:00 2001 From: chris Date: Tue, 6 Oct 2026 13:31:47 -0400 Subject: [PATCH] Share songs, with link previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Share ↗" on every song (web and mobile): the phone's share sheet, or copy the link on desktop. Links go to a new song page, /t/?from=you, with big artwork and open-in buttons for every service. - Server-rendered previews (Open Graph / Twitter cards) for song pages and profiles: title, who shared it or what they're playing, and the album art, avatar or logo. The web server fetches from the API over the compose network (INTERNAL_API_URL). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/mobile/src/components/TrackCard.tsx | 18 +- apps/web/src/app/globals.css | 3 + apps/web/src/app/layout.tsx | 4 + apps/web/src/app/t/[id]/TrackClient.tsx | 38 ++++ apps/web/src/app/t/[id]/page.tsx | 28 +++ .../src/app/u/[username]/ProfileClient.tsx | 144 +++++++++++++++ apps/web/src/app/u/[username]/page.tsx | 165 +++--------------- apps/web/src/components/ShareButton.tsx | 32 ++++ apps/web/src/components/TrackCard.tsx | 7 +- apps/web/src/lib/server.ts | 16 ++ docker-compose.yml | 3 + packages/shared/src/links.test.ts | 9 +- packages/shared/src/links.ts | 9 + 13 files changed, 332 insertions(+), 144 deletions(-) create mode 100644 apps/web/src/app/t/[id]/TrackClient.tsx create mode 100644 apps/web/src/app/t/[id]/page.tsx create mode 100644 apps/web/src/app/u/[username]/ProfileClient.tsx create mode 100644 apps/web/src/components/ShareButton.tsx create mode 100644 apps/web/src/lib/server.ts diff --git a/apps/mobile/src/components/TrackCard.tsx b/apps/mobile/src/components/TrackCard.tsx index 6d61882..b9bf8a3 100644 --- a/apps/mobile/src/components/TrackCard.tsx +++ b/apps/mobile/src/components/TrackCard.tsx @@ -1,6 +1,8 @@ import { Image } from 'expo-image'; -import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; -import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared'; +import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native'; +import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, shareText, trackUrl, type StreamingService, type TrackDTO } from '@musicshare/shared'; +import { WEB_URL } from '@/lib/config'; +import { useSession } from '@/lib/session'; import { showError } from '@/lib/errors'; import { colors, fonts, radius, space } from '@/lib/theme'; import { Button } from './ui'; @@ -11,7 +13,15 @@ import { Button } from './ui'; */ const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link")); +/** The phone's share sheet, with a link to the song's page (which opens it in any service). */ +function shareTrack(track: TrackDTO, from: string | undefined) { + const url = trackUrl(WEB_URL, track.id, from); + // Android shows only `message`; iOS shows `url` as a rich link next to it. + Share.share({ message: `${shareText(track)} ${url}`, url, title: shareText(track) }).catch((e) => showError(e, "Couldn't share")); +} + export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) { + const { me } = useSession(); const primary = preferred ? openUrl(track, preferred) : null; const others = STREAMING_SERVICES.filter((s) => s !== preferred); @@ -50,6 +60,9 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St ); })} + shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song"> + Share ↗ + @@ -72,6 +85,7 @@ const styles = StyleSheet.create({ title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 }, artist: { color: colors.text, opacity: 0.85, fontSize: 14 }, muted: { color: colors.muted }, + share: { backgroundColor: 'rgba(123,47,247,0.28)', borderColor: 'rgba(123,47,247,0.5)' }, primary: { alignSelf: 'flex-start', marginTop: space.sm }, services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 }, service: { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3991d88..a76e4f8 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -99,6 +99,9 @@ input:focus, textarea:focus, select:focus { .legal { line-height: 1.6; } .legal h2 { margin-top: 1.5rem; } .legal ul { padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.45rem; } +.share-link { font: inherit; cursor: pointer; color: var(--text); } +.song-page { display: flex; flex-direction: column; gap: 1.25rem; align-items: stretch; } +.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); } .avatar-picker { display: flex; gap: 1rem; align-items: center; } .avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; } .grow { flex: 1; min-width: 0; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 27ac289..eeedc9f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -8,7 +8,11 @@ import './globals.css'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' }); +// Previews need absolute image URLs. In production the API lives at /api, so its origin is the site's. +const siteUrl = process.env.SITE_URL ?? new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3737').origin; + export const metadata: Metadata = { + metadataBase: new URL(siteUrl), title: 'musicshare', description: "See what your friends are listening to, and play it on your own service.", applicationName: 'musicshare', diff --git a/apps/web/src/app/t/[id]/TrackClient.tsx b/apps/web/src/app/t/[id]/TrackClient.tsx new file mode 100644 index 0000000..cca0824 --- /dev/null +++ b/apps/web/src/app/t/[id]/TrackClient.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from 'next/link'; +import useSWR from 'swr'; +import type { TrackDTO } from '@musicshare/shared'; +import { TrackCard } from '@/components/TrackCard'; +import { fetcher, useMe } from '@/lib/api'; + +/** A shared song: big artwork and "open in" buttons for every service. */ +export function TrackClient({ id, from }: { id: string; from: string | null }) { + const { me } = useMe(); + const { data: track, error } = useSWR(`/tracks/${id}`, fetcher); + + if (error) return

This song couldn't be found.

; + if (!track) return null; + return ( +
+ {from && ( +

+ @{from} shared a song with you +

+ )} +
+ {track.artworkUrl && } + +
+ {!me && ( +
+

See what your friends are playing

+

musicshare shows you what friends are listening to right now, and opens it in your app.

+ + Join musicshare + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/t/[id]/page.tsx b/apps/web/src/app/t/[id]/page.tsx new file mode 100644 index 0000000..64dfbc0 --- /dev/null +++ b/apps/web/src/app/t/[id]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import type { TrackDTO } from '@musicshare/shared'; +import { serverGet } from '@/lib/server'; +import { TrackClient } from './TrackClient'; + +type Props = { params: Promise<{ id: string }>; searchParams: Promise<{ from?: string }> }; + +export async function generateMetadata({ params, searchParams }: Props): Promise { + const [{ id }, { from }] = await Promise.all([params, searchParams]); + const track = await serverGet(`/tracks/${encodeURIComponent(id)}`); + if (!track) return { title: 'musicshare' }; + const title = `${track.title} · ${track.artist}`; + const description = from + ? `@${from} shared this on musicshare. Play it on Spotify, Apple Music, YouTube Music and more.` + : `Play it on Spotify, Apple Music, YouTube Music and more.`; + const images = [track.artworkUrl ? { url: track.artworkUrl, width: 1000, height: 1000 } : { url: '/icon-512.png', width: 512, height: 512 }]; + return { + title: `${title} · musicshare`, + description, + openGraph: { title, description, type: 'music.song', images }, + twitter: { card: 'summary', title, description, images: [track.artworkUrl ?? '/icon-512.png'] }, + }; +} + +export default async function TrackPage({ params, searchParams }: Props) { + const [{ id }, { from }] = await Promise.all([params, searchParams]); + return ; +} diff --git a/apps/web/src/app/u/[username]/ProfileClient.tsx b/apps/web/src/app/u/[username]/ProfileClient.tsx new file mode 100644 index 0000000..5659c64 --- /dev/null +++ b/apps/web/src/app/u/[username]/ProfileClient.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { motion } from 'motion/react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useState } from 'react'; +import useSWR from 'swr'; +import { profileUrl, type ProfileDTO } from '@musicshare/shared'; +import { Avatar } from '@/components/Avatar'; +import { NowPlayingCard } from '@/components/NowPlayingCard'; +import { ProfileMenu } from '@/components/ProfileMenu'; +import { QrCode } from '@/components/QrCode'; +import { RecentPlays } from '@/components/RecentPlays'; +import { TrackCard } from '@/components/TrackCard'; +import { api, fetcher, useMe } from '@/lib/api'; +import { useRealtime } from '@/lib/useRealtime'; + +export function ProfileClient() { + const { username } = useParams<{ username: string }>(); + const { me } = useMe(); + const { data: profile, error, mutate } = useSWR(`/users/${username}`, fetcher); + const [busy, setBusy] = useState(false); + const [showQr, setShowQr] = useState(false); + + // Live updates arrive for people you follow (and yourself) + useRealtime(Boolean(me), (msg) => { + if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) { + // A new track moves the previous one into "Recently played", so refetch; otherwise patch in place. + const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id; + mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack }); + } + }); + + if (error) return

User not found.

; + if (!profile) return null; + + const togglePin = async () => { + setBusy(true); + try { + await api(`/users/${username}/pin`, { method: profile.isPinned ? 'DELETE' : 'PUT', body: profile.isPinned ? undefined : {} }); + await mutate(); + } catch (err) { + alert((err as Error).message); + } + setBusy(false); + }; + + const toggleFollow = async () => { + setBusy(true); + await api(`/users/${username}/follow`, { method: profile.isFollowing ? 'DELETE' : 'POST', body: {} }); + await mutate(); + setBusy(false); + }; + + const { user } = profile; + const preferred = me?.preferredService; + + return ( +
+ + +
+

{user.displayName ?? user.username}

+
+ @{user.username} + {profile.followsYou && !profile.isSelf && Follows you} +
+
+
+ + {profile.followerCount} {profile.followerCount === 1 ? 'follower' : 'followers'} + + + {profile.followingCount} following + +
+ {user.bio &&

{user.bio}

} + {profile.isSelf ? ( +
+ + + Edit profile + +
+ ) : me ? ( +
+ {profile.isFollowing && ( + + )} + {!profile.isBlocked && ( + + )} + mutate()} /> +
+ ) : ( + + Sign up to follow + + )} +
+ + {profile.isBlocked && ( +

+ You blocked @{user.username}. They can't see your profile or what you play, and you won't see theirs. Unblock + them from the ⋯ menu. +

+ )} + + {profile.isSelf && showQr && ( +
+ +

Friends scan this in the musicshare app (or their camera) to open your profile.

+
+ )} + + {profile.featured && ( +
+

Featured song

+ + {profile.featured.comment &&
{profile.featured.comment}
} +
+ )} + + {profile.nowPlaying ? ( + + ) : ( +

Nothing playing right now.

+ )} + + +
+ ); +} diff --git a/apps/web/src/app/u/[username]/page.tsx b/apps/web/src/app/u/[username]/page.tsx index 0c8f0ce..0e2754f 100644 --- a/apps/web/src/app/u/[username]/page.tsx +++ b/apps/web/src/app/u/[username]/page.tsx @@ -1,144 +1,29 @@ -'use client'; +import type { Metadata } from 'next'; +import type { ProfileDTO } from '@musicshare/shared'; +import { serverGet } from '@/lib/server'; +import { ProfileClient } from './ProfileClient'; -import { motion } from 'motion/react'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { useState } from 'react'; -import useSWR from 'swr'; -import { profileUrl, type ProfileDTO } from '@musicshare/shared'; -import { Avatar } from '@/components/Avatar'; -import { NowPlayingCard } from '@/components/NowPlayingCard'; -import { ProfileMenu } from '@/components/ProfileMenu'; -import { QrCode } from '@/components/QrCode'; -import { RecentPlays } from '@/components/RecentPlays'; -import { TrackCard } from '@/components/TrackCard'; -import { api, fetcher, useMe } from '@/lib/api'; -import { useRealtime } from '@/lib/useRealtime'; +type Props = { params: Promise<{ username: string }> }; + +/** Link previews (iMessage, Discord, WhatsApp…): who it is, and what they're playing. */ +export async function generateMetadata({ params }: Props): Promise { + const { username } = await params; + const profile = await serverGet(`/users/${encodeURIComponent(username)}`); + if (!profile) return { title: 'musicshare' }; + const name = profile.user.displayName ?? profile.user.username; + const np = profile.nowPlaying; + const description = np?.isPlaying + ? `Listening to ${np.track.title} by ${np.track.artist}` + : (profile.user.bio ?? `See what ${name} is listening to on musicshare.`); + const image = (np?.isPlaying && np.track.artworkUrl) || profile.user.avatarUrl || '/icon-512.png'; + return { + title: `${name} (@${profile.user.username}) · musicshare`, + description, + openGraph: { title: `${name} on musicshare`, description, type: 'profile', images: [{ url: image }] }, + twitter: { card: 'summary', title: `${name} on musicshare`, description, images: [image] }, + }; +} export default function ProfilePage() { - const { username } = useParams<{ username: string }>(); - const { me } = useMe(); - const { data: profile, error, mutate } = useSWR(`/users/${username}`, fetcher); - const [busy, setBusy] = useState(false); - const [showQr, setShowQr] = useState(false); - - // Live updates arrive for people you follow (and yourself) - useRealtime(Boolean(me), (msg) => { - if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) { - // A new track moves the previous one into "Recently played", so refetch; otherwise patch in place. - const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id; - mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack }); - } - }); - - if (error) return

User not found.

; - if (!profile) return null; - - const togglePin = async () => { - setBusy(true); - try { - await api(`/users/${username}/pin`, { method: profile.isPinned ? 'DELETE' : 'PUT', body: profile.isPinned ? undefined : {} }); - await mutate(); - } catch (err) { - alert((err as Error).message); - } - setBusy(false); - }; - - const toggleFollow = async () => { - setBusy(true); - await api(`/users/${username}/follow`, { method: profile.isFollowing ? 'DELETE' : 'POST', body: {} }); - await mutate(); - setBusy(false); - }; - - const { user } = profile; - const preferred = me?.preferredService; - - return ( -
- - -
-

{user.displayName ?? user.username}

-
- @{user.username} - {profile.followsYou && !profile.isSelf && Follows you} -
-
-
- - {profile.followerCount} {profile.followerCount === 1 ? 'follower' : 'followers'} - - - {profile.followingCount} following - -
- {user.bio &&

{user.bio}

} - {profile.isSelf ? ( -
- - - Edit profile - -
- ) : me ? ( -
- {profile.isFollowing && ( - - )} - {!profile.isBlocked && ( - - )} - mutate()} /> -
- ) : ( - - Sign up to follow - - )} -
- - {profile.isBlocked && ( -

- You blocked @{user.username}. They can't see your profile or what you play, and you won't see theirs. Unblock - them from the ⋯ menu. -

- )} - - {profile.isSelf && showQr && ( -
- -

Friends scan this in the musicshare app (or their camera) to open your profile.

-
- )} - - {profile.featured && ( -
-

Featured song

- - {profile.featured.comment &&
{profile.featured.comment}
} -
- )} - - {profile.nowPlaying ? ( - - ) : ( -

Nothing playing right now.

- )} - - -
- ); + return ; } diff --git a/apps/web/src/components/ShareButton.tsx b/apps/web/src/components/ShareButton.tsx new file mode 100644 index 0000000..225162e --- /dev/null +++ b/apps/web/src/components/ShareButton.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useState } from 'react'; +import { shareText, trackUrl, type TrackDTO } from '@musicshare/shared'; +import { useMe } from '@/lib/api'; + +/** Share a song: the phone's share sheet where there is one, otherwise copy the link. */ +export function ShareButton({ track }: { track: TrackDTO }) { + const { me } = useMe(); + const [copied, setCopied] = useState(false); + + const share = async () => { + const url = trackUrl(window.location.origin, track.id, me?.username); + if (navigator.share) { + try { + await navigator.share({ title: shareText(track), text: shareText(track), url }); + return; + } catch (err) { + if ((err as Error).name === 'AbortError') return; // they closed the sheet + } + } + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/TrackCard.tsx b/apps/web/src/components/TrackCard.tsx index 28cd709..28585e3 100644 --- a/apps/web/src/components/TrackCard.tsx +++ b/apps/web/src/components/TrackCard.tsx @@ -1,6 +1,7 @@ 'use client'; import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared'; +import { ShareButton } from './ShareButton'; /** * Artwork + title, with a primary button for the viewer's preferred service @@ -11,17 +12,20 @@ export function TrackCard({ track, preferred, compact, + hideArtwork, }: { track: TrackDTO; preferred: StreamingService | null | undefined; compact?: boolean; + /** When the page already shows the artwork big. */ + hideArtwork?: boolean; }) { const others = STREAMING_SERVICES.filter((s) => s !== preferred); const primary = preferred ? openUrl(track, preferred) : null; return (
- {track.artworkUrl ? ( + {hideArtwork ? null : track.artworkUrl ? ( ) : (
♪
@@ -53,6 +57,7 @@ export function TrackCard({ ); })} +
diff --git a/apps/web/src/lib/server.ts b/apps/web/src/lib/server.ts new file mode 100644 index 0000000..fd8a92e --- /dev/null +++ b/apps/web/src/lib/server.ts @@ -0,0 +1,16 @@ +import 'server-only'; + +/** + * For server-rendered link previews. Inside Docker the web server reaches the API + * by service name (INTERNAL_API_URL); elsewhere it uses the public URL. + */ +const INTERNAL_API = process.env.INTERNAL_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; + +export async function serverGet(path: string): Promise { + try { + const res = await fetch(INTERNAL_API + path, { next: { revalidate: 60 }, signal: AbortSignal.timeout(3000) }); + return res.ok ? ((await res.json()) as T) : null; + } catch { + return null; // previews are best-effort; the page itself still loads + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9a1567e..ed55df0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,9 @@ services: # Optional donation link shown on the website, e.g. DONATION_URL=https://ko-fi.com/you NEXT_PUBLIC_DONATION_URL: ${DONATION_URL:-} image: musicshare-web:local + environment: + # Server-side rendering (link previews) talks to the API inside the compose network. + INTERNAL_API_URL: http://api:4000 ports: - "${WEB_PORT:-3737}:3737" init: true diff --git a/packages/shared/src/links.test.ts b/packages/shared/src/links.test.ts index 225a761..0c19df4 100644 --- a/packages/shared/src/links.test.ts +++ b/packages/shared/src/links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseProfileLink, profileUrl } from './links'; +import { parseProfileLink, profileUrl, trackUrl } from './links'; describe('profileUrl', () => { it('builds a website profile link, tolerating a trailing slash', () => { @@ -34,3 +34,10 @@ describe('parseProfileLink', () => { expect(parseProfileLink(text)).toBeNull(); }); }); + +describe('trackUrl', () => { + it('links to the song page, optionally saying who shared it', () => { + expect(trackUrl('https://m.test/', 'abc')).toBe('https://m.test/t/abc'); + expect(trackUrl('https://m.test', 'abc', 'alice')).toBe('https://m.test/t/abc?from=alice'); + }); +}); diff --git a/packages/shared/src/links.ts b/packages/shared/src/links.ts index 08d515d..2ebdfff 100644 --- a/packages/shared/src/links.ts +++ b/packages/shared/src/links.ts @@ -29,6 +29,15 @@ export function parseProfileLink(text: string): string | null { return match ? valid(decodeURIComponent(match[1]!)) : null; } +/** Shareable song page on the website, e.g. https://musicshare.app/t/?from=alice */ +export function trackUrl(webBase: string, trackId: string, from?: string | null): string { + const url = `${webBase.replace(/\/+$/, '')}/t/${encodeURIComponent(trackId)}`; + return from ? `${url}?from=${encodeURIComponent(from)}` : url; +} + +/** What goes in the share sheet next to the link. */ +export const shareText = (track: { title: string; artist: string }) => `${track.title} by ${track.artist}`; + function valid(name: string): string | null { const username = name.toLowerCase(); return USERNAME.test(username) ? username : null;