Skip to content

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, r reply, e emoji, 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)

typescript
// 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 free

Audio 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 audioAudioContext stays 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 data

Key User Flows

1. First-Time Listener (Mobile)

2. Power User Desktop

3. Offline Commute

4. Social Discovery


Responsive Breakpoints

BreakpointWidthLayout
Mobile< 640pxStacked: Browse tabs bottom nav, Listen = full player + collapsible chat, Chat = bottom sheet, Profile = stacked cards
Tablet640–1024pxBrowse: 2-col grid, Listen: player left + chat right (resizable), Chat: sidebar + main, Profile: 2-col
Desktop1024–1440pxBrowse: 3-col grid, Listen: 3-pane (visualizer
Wide> 1440pxBrowse: 4-col grid, Listen: 4-pane (add presence list), max-width container

PWA Configuration

json
// 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" }
  ]
}
typescript
// 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
  • Languagelang attribute, RTL support for Arabic/Hebrew

Configuration

toml
# 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 = false

Dependencies (Key)

PackagePurpose
react, react-domUI framework
react-router-domRouting
@tanstack/react-queryServer state (frequency list)
zustandClient state (player, chat, settings)
@radix-ui/*Accessible primitives
tailwindcssStyling
@ifm/sdkIFM protocol (WASM)
@ifm/opus-wasmOpus decode (WASM, AudioWorklet)
workbox / vite-plugin-pwaService Worker, PWA
idbIndexedDB wrapper
date-fnsTime formatting
viteBuild tool
vitest, playwrightTesting

Implementation Phases

Phase 1: Foundation

  • [ ] Vite + React + TypeScript + Tailwind + PWA scaffold
  • [ ] @ifm/sdk WASM 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

  • 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

Released under the MIT License.