IFM Listener — End-User Web App
Status: ✅ Core demo implemented (
packages/sdk/examples/listener/) — this document is the full design spec for the production app.Implemented today: frequencies on air (live registry + manual tune) with automatic connection — the receiver is idle until a frequency is selected: it dials nothing on load, and the Frequencies on air list is fetched from the directory hub over plain HTTP (
GET /discover, no socket) so running public frequencies appear while the receiver stays disconnected. Tuning a frequency resolves the closest hop: a close peer's hub first, then the nearest pool relay by round-trip time, then the station's origin directly — shown in the player ("via peer / via relay / direct to station") — and it keeps running even when no relay or peer is in range; protected frequencies resolve their station when you tune them (access-key hash; key never sent in clear); live Opus/WebM playback with mid-stream join + autoplay unlock; chat; file receive.
The Listener app is the consumer-facing interface for discovering, listening to, and participating in IFM frequencies. Think Spotify + Discord + TuneIn built on a peer-to-peer mesh, with the web as another interface into that mesh — the same mesh the desktop apps join. No accounts required, works offline-first.
Target Audience
- General listeners — Music, talk, news, sports, ambient
- Community members — Chat, presence, social features
- Mobile users — Phone, tablet, car audio (Android Auto / CarPlay ready)
- Power users — Multiple frequencies, recording, customization
- Accessibility users — Screen readers, keyboard nav, high contrast, reduced motion
Core Features
1. Frequency Browser (/) — Home & Discover
The front door — search, filter, explore
Card details:
- Frequency name + namespace/channel
- Live listener count (real-time via presence)
- Status badge: 🟢 LIVE / 💬 ACTIVE / 🎙️ BROADCASTING / 📊 DATA / ⏸️ IDLE
- Bitrate + codec (if audio)
- Quick actions: Listen (navigate), Follow (notify), Share (copy deep link)
Filters:
- Type: Public / Protected (need invite) / Hidden (discoverable only via link)
- Category: Music, Talk, News, Sports, IoT/Sensors, Gaming, Emergency
- Listener range: 0-10, 10-100, 100-1K, 1K-10K, 10K+
- Broadcaster: Live now / Scheduled / None
- Tags: Genre, language, region, mood
- Favorites only (⭐)
Sort: Listeners ↓/↑, Recent activity, Alphabetical, Bitrate, Added date
2. Listen Page (/listen/:frequency) — Audio Player + Chat
Full-screen immersive listening experience
Audio Player Controls:
- Play/Pause, Previous/Next (if playlist), Seek bar with buffered preview
- Volume slider + mute, Quality selector (bitrate options from broadcaster)
- PiP (Picture-in-Picture) — Mini player floats on top, survives tab switch
- Recording — One-click MediaRecorder → WebM/Opus → Download
- Background audio — Service worker keeps playing when tab hidden/closed (mobile)
Chat Sidebar:
- Real-time messages (IFM text packets)
- Threading: replies, reactions (emoji), mentions (@user)
- Rich text: Markdown, code blocks, links, embeds (images, videos)
- Presence: online 🟢, idle 🟡 (5min), away 🔴 (30min), offline ⚫
- Typing indicators, read receipts (optional)
- Popout to separate window (multi-monitor)
- Moderation tools visible only to mods
3. Chat (/chat/:frequency) — Dedicated Chat Experience
Full-screen chat for text-heavy frequencies
- Message threading (collapsible threads)
- Search: full-text history (IndexedDB)
- Moderation: delete, timeout, ban, slow mode, pin (mods only)
- Export: Download chat log (JSON/TXT/HTML)
- Keyboard shortcuts:
/focus search,rreply,eemoji,↑edit last - Mobile: bottom sheet, swipe to dismiss
4. Profile & Settings (/profile)
Identity + preferences + social
5. Offline & Background (PWA)
Works without network, survives app close
- Service Worker — Caches app shell, audio chunks (last 5min), chat messages
- Background Sync — Queues outgoing chat messages, sends when online
- Background Audio — Media Session API: lock screen controls, Bluetooth media keys, car integration
- Offline Indicator — Subtle banner: "You're offline. Messages will send when connected."
- Install Prompt — "Add to Home Screen" (PWA manifest)
Architecture
Connection Strategy (Resilient)
// src/services/connection.ts
async function connect(): Promise<IFMRadio> {
// 1. Connect through the relay hub (the web's path into the mesh)
try {
const radio = await IFM.create({ relay });
await radio.connect();
return radio; // Success: joined the mesh through the hub
} catch (e) {
console.warn('relay failed, trying a backup hub');
}
// 2. Fallback to a backup hub (another relay on the same mesh)
try {
const radio = await IFM.create({
relay: CONFIG.fallback_ws_url // wss://relay.ifm.app/ifm
});
await radio.connect();
return radio; // Success: joined through the backup hub
} catch (e) {
console.error('All connections failed');
throw e;
}
}
// 3. BroadcastChannel for same-origin tabs (instant sync)
// If tab A tunes to music.lofi, tab B gets the audio stream freeAudio Pipeline (Receive Side)
Key features:
- Adaptive jitter buffer — Monitors packet arrival variance, adjusts 50-500ms
- Packet Loss Concealment (PLC) — Opus built-in + waveform substitution
- Quality adaptation — Requests lower bitrate from broadcaster if buffer underruns (via control packet)
- Visualizer — AnalyserNode → Canvas/WebGL bars, waveform, or circular
- Background audio —
AudioContextstays alive via Media Session API
Project Structure
packages/sdk/examples/listener/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── vite.pwa.config.ts # Workbox config
├── tailwind.config.js
├── public/
│ ├── manifest.json # PWA manifest
│ ├── sw.js # Service worker (generated)
│ ├── wasm/ # @ifm/sdk + @ifm/opus-wasm
│ └── icons/ # PWA icons (192, 512, maskable)
├── src/
│ ├── main.tsx
│ ├── App.tsx # Router + providers + SW registration
│ ├── styles/globals.css
│ ├── components/
│ │ ├── ui/ # Radix primitives
│ │ ├── layout/ # Header, BottomNav (mobile), Sidebar, PiPPlayer
│ │ ├── frequency/
│ │ │ ├── FrequencyCard.tsx
│ │ │ ├── FrequencyGrid.tsx
│ │ │ ├── FrequencyList.tsx
│ │ │ ├── CategoryTabs.tsx
│ │ │ ├── FilterDrawer.tsx
│ │ │ └── SortSelect.tsx
│ │ ├── player/
│ │ │ ├── AudioPlayer.tsx # Main player (Listen page)
│ │ │ ├── PiPPlayer.tsx # Picture-in-picture mini player
│ │ │ ├── Visualizer.tsx # Canvas/WebGL bars/waveform
│ │ │ ├── QualitySelector.tsx
│ │ │ ├── RecordingButton.tsx
│ │ │ └── PlaybackControls.tsx
│ │ ├── chat/
│ │ │ ├── MessageList.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── ThreadView.tsx
│ │ │ ├── PresenceList.tsx
│ │ │ ├── ModerationTools.tsx
│ │ │ └── SearchMessages.tsx
│ │ ├── profile/
│ │ │ ├── FollowedBroadcasters.tsx
│ │ │ ├── NotificationPrefs.tsx
│ │ │ ├── AudioSettings.tsx
│ │ │ ├── AppearanceSettings.tsx
│ │ │ ├── PrivacySettings.tsx
│ │ │ └── AvatarUpload.tsx
│ │ └── common/
│ │ ├── FollowButton.tsx
│ │ ├── ShareButton.tsx
│ │ ├── OfflineBanner.tsx
│ │ └── InstallPrompt.tsx
│ ├── pages/
│ │ ├── Browse.tsx
│ │ ├── Listen.tsx
│ │ ├── Chat.tsx
│ │ ├── Profile.tsx
│ │ └── Settings.tsx
│ ├── hooks/
│ │ ├── useBrowse.ts # Frequency search/filter/sort
│ │ ├── useAudioPlayer.ts # AudioContext, playback, quality
│ │ ├── usePresence.ts # Listener presence tracking
│ │ ├── useChat.ts # Messages, threading, reactions
│ │ ├── useFollows.ts # Follow/unfollow, notifications
│ │ ├── useOffline.ts # SW state, background sync
│ │ ├── useMediaSession.ts # Lock screen / media keys
│ │ └── usePWA.ts # Install prompt, update notification
│ ├── services/
│ │ ├── ifm.ts # SDK wrapper (connect, tune, subscribe)
│ │ ├── webrtc.ts # WebRTC signaling
│ │ ├── audio.ts # AudioWorklet decoder + jitter buffer
│ │ ├── storage.ts # IndexedDB (chat, cache, recordings)
│ │ ├── notifications.ts # Web Push + Permission API
│ │ ├── backgroundSync.ts # Queue + flush on online
│ │ └── mediaSession.ts # Media Session API
│ ├── stores/
│ │ ├── browseStore.ts # Frequencies, filters, sort
│ │ ├── playerStore.ts # Current freq, playback state, queue
│ │ ├── presenceStore.ts # Listeners, broadcasters, self
│ │ ├── chatStore.ts # Messages, threads, drafts
│ │ ├── followStore.ts # Followed broadcasters, notifs
│ │ ├── settingsStore.ts # All user preferences
│ │ └── offlineStore.ts # Outbound queue, cached audio
│ ├── types/
│ │ ├── ifm.ts # IFM types (mirrors SDK)
│ │ ├── audio.ts
│ │ ├── chat.ts
│ │ ├── profile.ts
│ │ └── pwa.ts
│ └── workers/
│ ├── audioDecoder.ts # AudioWorklet: Opus decode + jitter
│ └── visualizer.ts # AudioWorklet: Analyser dataKey User Flows
1. First-Time Listener (Mobile)
2. Power User Desktop
3. Offline Commute
4. Social Discovery
Responsive Breakpoints
| Breakpoint | Width | Layout |
|---|---|---|
| Mobile | < 640px | Stacked: Browse tabs bottom nav, Listen = full player + collapsible chat, Chat = bottom sheet, Profile = stacked cards |
| Tablet | 640–1024px | Browse: 2-col grid, Listen: player left + chat right (resizable), Chat: sidebar + main, Profile: 2-col |
| Desktop | 1024–1440px | Browse: 3-col grid, Listen: 3-pane (visualizer |
| Wide | > 1440px | Browse: 4-col grid, Listen: 4-pane (add presence list), max-width container |
PWA Configuration
// public/manifest.json
{
"name": "IFM Listener",
"short_name": "IFM",
"description": "Listen to live radio on the IFM mesh",
"start_url": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0f0f0f",
"theme_color": "#0f0f0f",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"categories": ["music", "entertainment", "social"],
"shortcuts": [
{ "name": "Browse", "url": "/", "icons": [{ "src": "/icons/browse.png", "sizes": "192x192" }] },
{ "name": "Following", "url": "/profile", "icons": [{ "src": "/icons/following.png", "sizes": "192x192" }] }
],
"protocol_handlers": [
{ "protocol": "web+ifm", "url": "/listen/%s" }
]
}// vite.pwa.config.ts (Workbox)
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt'],
manifest: false, // use public/manifest.json
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.ifm\.app\/.*/,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 86400 } }
},
{
urlPattern: /\.(?:opus|webm|ogg)$/,
handler: 'CacheFirst',
options: { cacheName: 'audio-cache', expiration: { maxEntries: 50, maxAgeSeconds: 604800 } }
}
],
navigateFallback: '/index.html',
},
devOptions: { enabled: true }
})
]
});Accessibility (WCAG 2.1 AA)
- Keyboard navigation — All interactive elements reachable, logical tab order, skip links
- Screen readers — ARIA labels, live regions for player state (playing/paused, track change), chat announcements
- High contrast — Theme supports
prefers-contrast: more; AMOLED dark mode - Reduced motion — Respects
prefers-reduced-motion; disables visualizer, transitions, auto-scroll - Focus management — Visible focus rings, focus trap in modals, restore focus on close
- Audio controls — Volume, mute, pause always accessible; no auto-play without interaction
- Language —
langattribute, RTL support for Arabic/Hebrew
Configuration
# listener.config.toml (loaded from public/config.toml or localStorage)
[listener]
default_frequency = "music.general"
auto_play = true
cache_audio_seconds = 300 # 5min buffer for offline
max_cached_frequencies = 20
[audio]
default_quality = "auto" # "auto" | "64" | "128" | "256" | "320"
crossfade_seconds = 5
volume_normalization = true
background_playback = true
data_saver = false # force 64kbps on cellular
[visualizer]
type = "bars" # "bars" | "waveform" | "circular" | "none"
fps = 30
sensitivity = 1.0
[chat]
max_messages = 1000
threading = true
reactions = true
markdown = true
typing_indicator = true
read_receipts = false
[notifications]
push_enabled = true
events = ["follow_live", "mention", "reply", "new_episode"]
in_app = true
email = false
[pwa]
install_prompt = true
auto_update = true
offline_banner = true
[privacy]
show_presence = true
allow_dms = false
analytics = falseDependencies (Key)
| Package | Purpose |
|---|---|
react, react-dom | UI framework |
react-router-dom | Routing |
@tanstack/react-query | Server state (frequency list) |
zustand | Client state (player, chat, settings) |
@radix-ui/* | Accessible primitives |
tailwindcss | Styling |
@ifm/sdk | IFM protocol (WASM) |
@ifm/opus-wasm | Opus decode (WASM, AudioWorklet) |
workbox / vite-plugin-pwa | Service Worker, PWA |
idb | IndexedDB wrapper |
date-fns | Time formatting |
vite | Build tool |
vitest, playwright | Testing |
Implementation Phases
Phase 1: Foundation
- [ ] Vite + React + TypeScript + Tailwind + PWA scaffold
- [ ]
@ifm/sdkWASM integration (connect, tune, subscribe) - [ ] Basic routing: Browse, Listen, Chat, Profile
- [ ] AudioWorklet scaffold (Opus decoder + jitter buffer)
- [ ] Service Worker + offline caching
Phase 2: Core Listening
- [ ] Browse page: frequency grid, search, filter, sort, categories
- [ ] Listen page: audio player, visualizer, quality selector
- [ ] PiP player + Media Session API (lock screen controls)
- [ ] Recording (MediaRecorder → download)
- [ ] Background audio + offline buffer
Phase 3: Chat & Social
- [ ] Chat sidebar (Listen page) + dedicated Chat page
- [ ] Threading, reactions, mentions, rich text
- [ ] Presence indicators (online/idle/away)
- [ ] Follow broadcasters + notification preferences
- [ ] Web Push notifications (go-live alerts)
Phase 4: Profile & Settings
- [ ] Profile page: followed broadcasters, activity
- [ ] Settings: audio, appearance, notifications, privacy
- [ ] Theme system (dark/light/AMOLED/system)
- [ ] Data export / account delete
Phase 5: PWA Polish
- [ ] Install prompt + update notification
- [ ] Background sync (chat queue)
- [ ] Protocol handler (
web+ifm://) - [ ] Shortcuts (Browse, Following)
- [ ] Offline UX (cached browse, audio buffer)
Phase 6: Advanced & Accessibility
- [ ] Adaptive quality (bandwidth detection)
- [ ] Crossfade between tracks
- [ ] Sleep timer, alarm clock (radio alarm)
- [ ] CarPlay / Android Auto manifest
- [ ] Full accessibility audit + screen reader testing
- [ ] Performance: virtualized lists, lazy loading, code splitting
Related Docs
- Station App — Broadcaster studio (content originates here)
- Relay Dashboard — Operator view (Listener frequencies managed here)
- SDK API — Core protocol library
- Node UI — Native node operator terminal