Add Birdex chorus button

New BirdexChorusButton plays every species' Wikipedia recording at once
— a dawn chorus for the whole life list. Hides itself when no species
has usable audio, and the feed-card variant swallows clicks so toggling
playback doesn't navigate away from the NoteCard.
This commit is contained in:
Alex Gleason
2026-05-05 17:24:57 -05:00
parent df38cfdbca
commit a6bfd2cb68
3 changed files with 406 additions and 101 deletions
+81 -85
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Bird, ExternalLink, MessageCircle } from 'lucide-react';
import { Bird } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { NostrEvent } from '@nostrify/nostrify';
import { BirdSongPlayer } from '@/components/BirdSongPlayer';
import { Skeleton } from '@/components/ui/skeleton';
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
@@ -87,12 +88,11 @@ export function BirdDetectionContent({ event, className }: BirdDetectionContentP
const commonName = summary?.title ?? altSpecies?.common ?? 'Unknown species';
const extract = summary?.extract;
const thumbnail = sanitizeUrl(summary?.thumbnail?.source);
const articleUrl = sanitizeUrl(summary?.articleUrl);
// "Discuss" routes the user to Ditto's external-content page for this
// species' Wikidata URL. Other users' kind 2473 detections and NIP-22
// comments both attach to the same `i`-tag identifier, so the discussion
// thread aggregates naturally across clients.
// The whole card routes to Ditto's external-content page for this
// species' Wikidata URL. Other users' kind 2473 detections and
// NIP-22 comments both attach to the same `i`-tag identifier, so
// the discussion thread aggregates naturally across clients.
const discussPath = wikidata ? `/i/${encodeURIComponent(wikidata.url)}` : undefined;
// When the user's own freeform note exists we show it above the
@@ -114,93 +114,89 @@ export function BirdDetectionContent({ event, className }: BirdDetectionContentP
return (
<div className={cn('mt-2', className)}>
<div className="flex overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
{/* Thumbnail panel */}
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
<Link
to={discussPath ?? '#'}
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex">
{/* Thumbnail panel */}
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
{timeStr}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
{timeStr}
</div>
</div>
{/* Text panel */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5 p-3.5">
<div className="min-w-0">
<div className="flex items-start gap-1.5">
<Bird aria-hidden className="mt-0.5 size-3.5 shrink-0 text-emerald-600 dark:text-amber-300" />
<h3 className="truncate text-[15px] font-semibold leading-tight">
{commonName}
</h3>
{/* Text panel */}
<div className="flex min-w-0 flex-1 items-start gap-3 p-3.5">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold leading-tight">
{commonName}
</h3>
{scientificName && (
<p className="mt-0.5 truncate text-xs italic text-muted-foreground">
{scientificName}
</p>
)}
</div>
{isLoading ? (
<div className="space-y-1.5 pt-0.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
) : extract ? (
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
{extract}
</p>
) : (
<p className="text-xs italic text-muted-foreground/70">
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
</p>
)}
</div>
{scientificName && (
<p className="mt-0.5 truncate pl-5 text-xs italic text-muted-foreground">
{scientificName}
</p>
{/* Reference recording from Wikipedia/Commons, when available.
* `BirdSongPlayer` returns null when the article has no
* usable audio, so the right-hand column collapses
* cleanly and the text reflows across the full card.
* The click handler stops propagation so toggling
* playback doesn't also navigate to `/i/...`. */}
{wikipediaTitle && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="shrink-0"
>
<BirdSongPlayer title={wikipediaTitle} ariaLabel={`${commonName} recording`} />
</div>
)}
</div>
{isLoading ? (
<div className="space-y-1.5 pt-0.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
) : extract ? (
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
{extract}
</p>
) : (
<p className="text-xs italic text-muted-foreground/70">
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
</p>
)}
{(articleUrl || discussPath) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-0.5">
{discussPath && (
<Link
to={discussPath}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<MessageCircle className="size-3" />
Discuss
</Link>
)}
{articleUrl && (
<a
href={articleUrl}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3" />
Wikipedia
</a>
)}
</div>
)}
</div>
</div>
</Link>
{note && (
<p className="mt-2 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
+279
View File
@@ -0,0 +1,279 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Play } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useBirdSong } from '@/hooks/useBirdSong';
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
import { cn } from '@/lib/utils';
/**
* Chorus play/pause button for a Birdex (kind 12473) life list.
*
* A single control that fires every species' reference recording from
* Wikipedia/Commons *at the same time*, producing an overlapping
* dawn-chorus effect. Each recording loops independently so the
* chorus sustains until the user hits pause.
*
* Architecture: the parent owns a single `isPlaying` flag. It renders
* one `BirdexChorusVoice` per species, each of which:
* 1. Resolves its Wikidata ID → Wikipedia title → Commons audio URL
* via the same hooks the tile thumbnails already call (so the
* title-resolution round-trips are cache hits).
* 2. Owns a hidden `<audio loop>` element.
* 3. Reacts to `isPlaying` by calling `play()` or `pause()` on its
* element, and reports ready-state / error-state back up so the
* button knows when to show a spinner and whether there's
* anything audible to play at all.
*
* Voices that fail to resolve audio (species whose Wikipedia article
* has no usable field recording) are silently skipped — the chorus
* plays whatever subset has audio. If nothing at all has audio the
* button hides itself rather than rendering a dead control.
*
* Note: every species' audio URL is fetched eagerly on mount. The
* cost is bounded by the number of species the user already sees as
* tiles, and every request is cached for 24h via TanStack Query, so
* a second visit is free.
*/
export interface BirdexChorusSpecies {
/** Wikidata entity ID, e.g. "Q26825". */
entityId: string;
}
interface BirdexChorusButtonProps {
species: BirdexChorusSpecies[];
className?: string;
}
type VoiceState = 'loading' | 'ready' | 'missing';
export function BirdexChorusButton({ species, className }: BirdexChorusButtonProps) {
const [isPlaying, setIsPlaying] = useState(false);
// Track each voice's readiness so we can disable the button while
// anything is still resolving, and hide it entirely when no voice
// has usable audio. A Map keyed by entityId so voices can update
// their slot without racing by array index.
const [voiceStates, setVoiceStates] = useState<Map<string, VoiceState>>(
() => new Map(species.map((s) => [s.entityId, 'loading' as VoiceState])),
);
// Keep the map in sync when the species list changes (e.g. the
// Birdex event is replaced with a newer version). Entries that
// disappear are dropped; new ones start as `loading`.
useEffect(() => {
setVoiceStates((prev) => {
const next = new Map<string, VoiceState>();
for (const s of species) {
next.set(s.entityId, prev.get(s.entityId) ?? 'loading');
}
return next;
});
}, [species]);
const reportState = useCallback((entityId: string, state: VoiceState) => {
setVoiceStates((prev) => {
if (prev.get(entityId) === state) return prev;
const next = new Map(prev);
next.set(entityId, state);
return next;
});
}, []);
const anyLoading = useMemo(
() => Array.from(voiceStates.values()).some((s) => s === 'loading'),
[voiceStates],
);
const readyCount = useMemo(
() => Array.from(voiceStates.values()).filter((s) => s === 'ready').length,
[voiceStates],
);
// Hide the button entirely once resolution settles and not a single
// species produced playable audio. While loading we still render
// (the skeleton indicates the chorus is being assembled).
//
// Crucially, the `BirdexChorusVoice` children must always render
// regardless of UI state — they're the hooks that drive the
// resolution we're waiting on. Returning early before rendering
// them would freeze the button in its initial "loading" state
// forever.
const hideButton = !anyLoading && readyCount === 0;
const showSkeleton = !hideButton && anyLoading && readyCount === 0;
const toggle = () => setIsPlaying((p) => !p);
return (
<>
{hideButton ? null : showSkeleton ? (
<Skeleton
className={cn('size-10 shrink-0 rounded-full', className)}
aria-hidden
/>
) : (
<button
type="button"
onClick={toggle}
aria-pressed={isPlaying}
aria-label={
isPlaying
? `Pause dawn chorus of ${readyCount} species`
: `Play dawn chorus of ${readyCount} species`
}
className={cn(
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
'transition-[transform,background-color,box-shadow] duration-200',
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
'focus-visible:ring-offset-background',
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
className,
)}
>
{isPlaying ? (
<EqualiserBars />
) : (
// Nudge the play triangle right by 1px so its visual
// centroid aligns with the circle's centre — the glyph's
// bounding box is wider on the right than the left.
<Play className="size-4 translate-x-px fill-current" aria-hidden />
)}
</button>
)}
{species.map((s) => (
<BirdexChorusVoice
key={s.entityId}
entityId={s.entityId}
isPlaying={isPlaying}
onStateChange={reportState}
/>
))}
</>
);
}
/**
* Four vertical bars bouncing with staggered CSS animations, shown
* inside the button while the chorus is playing. Matches the
* equaliser used by `BirdSongPlayer` so the chorus button and the
* per-species buttons are visually indistinguishable.
*/
function EqualiserBars() {
const delays = ['0ms', '120ms', '60ms', '180ms'];
return (
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
{delays.map((delay, i) => (
<span
key={i}
className={cn(
'block w-[2px] rounded-full bg-current',
'h-full origin-bottom motion-safe:animate-equaliser-bar',
'motion-reduce:scale-y-75',
)}
style={{ animationDelay: delay }}
/>
))}
</span>
);
}
interface BirdexChorusVoiceProps {
entityId: string;
isPlaying: boolean;
onStateChange: (entityId: string, state: VoiceState) => void;
}
/**
* A single voice in the chorus. Resolves Wikidata → Wikipedia title →
* Commons audio URL and renders a hidden `<audio loop>` element that
* tracks the shared play/pause state. Renders nothing visible.
*/
function BirdexChorusVoice({ entityId, isPlaying, onStateChange }: BirdexChorusVoiceProps) {
const { data: entity, isLoading: entityLoading, isError: entityError } =
useWikidataEntity(entityId);
const wikipediaTitle = entity?.wikipediaTitle ?? null;
// `useBirdSong` only fires once we have a Wikipedia title. While
// it's disabled its `isLoading` is false but `data` is undefined,
// so we gate readiness on the parent query's state too.
const { data: song, isLoading: songLoading, isError: songError } =
useBirdSong(wikipediaTitle);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrl = song?.audioUrl ?? null;
// Report our state upward whenever it changes. "missing" fires both
// when Wikidata has no enwiki sitelink and when Wikipedia has no
// usable recording — the UI treats both the same.
useEffect(() => {
if (entityError || (!entityLoading && !wikipediaTitle)) {
onStateChange(entityId, 'missing');
return;
}
if (entityLoading || songLoading) {
onStateChange(entityId, 'loading');
return;
}
if (songError || !audioUrl) {
onStateChange(entityId, 'missing');
return;
}
onStateChange(entityId, 'ready');
}, [
entityId,
entityError,
entityLoading,
wikipediaTitle,
songLoading,
songError,
audioUrl,
onStateChange,
]);
// Drive the hidden `<audio>` element from the shared flag. We
// don't forward `onPlay`/`onPause` events upward because the
// parent is the source of truth; bubbling them back would create
// feedback loops when (e.g.) the browser auto-pauses on tab hide.
useEffect(() => {
const el = audioRef.current;
if (!el || !audioUrl) return;
if (isPlaying) {
// `play()` rejects when autoplay is blocked or the source
// fails to load. Swallow it — the voice just drops out of the
// chorus rather than taking the whole button down.
el.play().catch(() => {});
} else {
el.pause();
// Reset to the start so the next Play gives a fresh chorus
// rather than picking up mid-phrase with every voice out of
// sync with wherever it happened to be paused.
try {
el.currentTime = 0;
} catch {
/* Some browsers throw on seek before metadata loads. */
}
}
}, [isPlaying, audioUrl]);
if (!audioUrl) return null;
return (
<audio
ref={audioRef}
src={audioUrl}
preload="auto"
loop
className="hidden"
aria-hidden
/>
);
}
+46 -16
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { Bird } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { BirdexChorusButton } from '@/components/BirdexChorusButton';
import { BirdexTile } from '@/components/BirdexTile';
import { parseBirdexEvent } from '@/lib/parseBirdex';
import { cn } from '@/lib/utils';
@@ -57,14 +58,24 @@ export function BirdexContent({ event, expanded, className }: BirdexContentProps
if (expanded) {
return (
<div className={cn('mt-2', className)}>
<div className="mb-3 flex items-center gap-2">
<Bird className="size-4 text-emerald-600 dark:text-amber-300" aria-hidden />
<h3 className="text-[15px] font-semibold leading-tight">
Birdex
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{entries.length} species
</span>
</h3>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
<h3 className="truncate text-[15px] font-semibold leading-tight">
Birdex
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{entries.length} species
</span>
</h3>
</div>
{/* Single control that plays every species' Wikipedia
* recording simultaneously — a dawn chorus for the whole
* life list. Hides itself when no species has usable audio. */}
<BirdexChorusButton
species={entries.map(({ entityId }) => ({ entityId }))}
className="shrink-0"
/>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
@@ -95,14 +106,33 @@ export function BirdexContent({ event, expanded, className }: BirdexContentProps
return (
<div className={cn('mt-2', className)}>
<div className="mb-2 flex items-center gap-2">
<Bird className="size-4 text-emerald-600 dark:text-amber-300" aria-hidden />
<span className="text-[15px] font-semibold leading-tight">
Birdex
</span>
<span className="text-sm text-muted-foreground">
· {entries.length} species
</span>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
<span className="text-[15px] font-semibold leading-tight">
Birdex
</span>
<span className="text-sm text-muted-foreground">
· {entries.length} species
</span>
</div>
{/* Same chorus button as the expanded view — plays the
* entire life list, not just the preview slice, because the
* button represents the whole event. Wrapped in a click
* swallower: the feed variant sits inside a clickable
* NoteCard, and toggling playback must not navigate away. */}
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="shrink-0"
>
<BirdexChorusButton
species={entries.map(({ entityId }) => ({ entityId }))}
/>
</div>
</div>
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8">