IFM Mobile Radio Listener — Product Documentation
The flagship mobile listener app. Zero-install PWA. Radio-simple. Built exclusively with IFM Protocol products.
Vision
Mobile-first. Radio-simple. No compromises.
IFM Mobile Radio Listener is not a demo. It's our public-facing product — the primary way listeners experience the IFM network.
| Principle | Implementation |
|---|---|
| Radio metaphor | Big frequency knob, instant tune, live indicator |
| Zero friction | Open URL → listening in < 2s. No login, no account, no permissions until needed |
| Works like radio | Background playback on lock screen, media controls, wake lock |
| Offline-capable | Cached station list, cached init segment = instant replay on reconnect |
| Native feel | PWA: "Add to Home Screen" → full-screen app, no browser chrome |
| Built on protocol | 100% @ifm/sdk-wasm — no custom protocol logic |
User Experience
First Visit (https://ifm.sh/pwa)
Daily Use (from Home Screen)
Protected Frequency
Features
Core Listening
| Feature | Description |
|---|---|
| Live station discovery | mDNS (local) + DHT (internet) via @ifm/sdk-wasm |
| Instant tune | Cached WebM init segment → audio in < 200ms |
| Frequency display | Large FM-style: 91.700 with optional name music.lofi |
| Signal quality | Visual bars: peer/relay/origin + RTT |
| Categories | Music, Talk, News, Sports, Community, Custom |
| Search | Real-time filter by number, name, category |
Playback
| Feature | Description |
|---|---|
| Background audio | Continues on lock screen, app switch, home screen |
| Media Session API | Lock screen controls (play/pause, next/prev, seek) |
| Wake Lock | Optional: keeps screen on during playback |
| Volume | System volume + in-app slider |
| Auto-reconnect | Network change → seamless re-tune |
| Mid-stream join | Cached init segment = join live stream instantly |
Social
| Feature | Description |
|---|---|
| Chat | Real-time text with station + listeners (slide-up panel) |
| Files | Receive files broadcast by station → Downloads/IFM/ |
| Reactions | Quick emoji: 👍 ❤️ 🔥 😂 |
| Share station | Web Share API → native sheet; QR code fallback |
Favorites & History
| Feature | Description |
|---|---|
| Favorites | Star stations → pinned top, persists (IndexedDB) |
| Recent | Last 20 played, timestamps |
| Categories | Auto-grouped by station category |
PWA Features
| Feature | Description |
|---|---|
| Installable | "Add to Home Screen" → standalone app |
| Offline | Station list cached 24h; init segment cached |
| Push notifications | "Station X is now live" (opt-in) |
| Auto-update | SW updates on refresh; prompt to reload |
| Deep links | https://ifm.sh/pwa#91.700 auto-tunes |
| Web Share Target | Receive shares from other apps |
Native App Feel Requirements (CRITICAL)
The PWA must feel indistinguishable from a native iOS/Android app when installed. Every interaction, animation, and performance characteristic must meet native standards.
Performance Budgets (Non-Negotiable)
| Metric | Target | Measurement |
|---|---|---|
| TTI (Time to Interactive) | < 1.5s on 4G | Lighthouse / WebPageTest |
| First Contentful Paint | < 800ms | Chrome DevTools |
| Audio Start Latency | < 200ms (cached) / < 800ms (cold) | Custom metric |
| Tap-to-Response | < 100ms | performance.now() |
| Frame Rate | 60fps sustained | DevTools Performance |
| Bundle Size (gzipped) | < 150KB initial / < 500KB total | Vite bundle analyzer |
| WASM Init | < 300ms | @ifm/sdk-wasm load time |
| Memory (idle) | < 50MB | DevTools Memory |
| Memory (playing) | < 100MB | DevTools Memory |
UI/UX Native Parity Checklist
iOS Safari / iOS PWA Specific
- [ ] Safe Area Insets —
env(safe-area-inset-*)for notches/home indicator - [ ] Rubber-band Scroll — Native momentum scrolling (
-webkit-overflow-scrolling: touch) - [ ] Pull-to-Refresh — Custom implementation matching iOS feel (resistance, bounce)
- [ ] Haptic Feedback —
navigator.vibrate()for key actions (tune, favorite, play) - [ ] Status Bar —
theme-color+apple-mobile-web-app-status-bar-style: black-translucent - [ ] Launch Screen —
apple-touch-startup-imagefor splash screen - [ ] Orientation Lock —
orientation: 'portrait-primary'in manifest - [ ] Back Swipe Gesture — Don't interfere with iOS edge swipe
Android Chrome / Android PWA Specific
- [ ] Material Motion — Motion spec compliant transitions (easing, duration)
- [ ] Edge-to-Edge —
display: 'standalone'+ transparent navigation bar - [ ] Back Gesture — Handle Android 14+ predictive back gesture
- [ ] Splash Screen — Auto-generated from manifest icons
- [ ] App Shortcuts — Manifest shortcuts for Favorites, Search
- [ ] Share Target — Receive shares from other apps (
share_targetin manifest)
Cross-Platform Native Behaviors
- [ ] 60fps Animations — All transitions use
transform/opacityonly (GPU accelerated) - [ ] Spring Physics — Use
motion(Framer Motion) with spring configs matching platform - [ ] Touch Ripple — Immediate visual feedback on press (
<Pressable>/ active states) - [ ] Skeleton Loaders — Not spinners; content-shaped placeholders
- [ ] Optimistic UI — Instant favorite/toggle, sync in background
- [ ] Keyboard Avoidance — Inputs don't get covered by virtual keyboard
- [ ] Focus Management — Logical tab order, visible focus rings (a11y)
- [ ] Reduced Motion — Respect
prefers-reduced-motion - [ ] High Contrast — Respect
prefers-contrast: more - [ ] Dark Mode Only — True black (
#0A0A0A) for OLED, no light theme
Framework Selection for Native Feel
| Layer | Choice | Rationale |
|---|---|---|
| UI Primitives | Radix UI (headless) | Unstyled, accessible, full control over styling/animation |
| Animation | Framer Motion (motion package) | Spring physics, layout animations, gesture handling, 60fps |
| Styling | TailwindCSS v4 + CSS Variables | Design tokens as CSS vars, JIT, zero runtime, dark mode native |
| State | TanStack Query + Zustand | Server state + client state, devtools, persistence |
| Routing | TanStack Router | Type-safe, file-based, loader patterns, suspense |
| Forms | React Hook Form + Zod | Performant, validation, native-feel inputs |
| Gestures | Framer Motion + @use-gesture/react | Drag, swipe, pinch, pan — native physics |
| Virtualization | @tanstack/react-virtual | 60fps lists with 10k+ items |
| Icons | Lucide React | Consistent, tree-shakable, 24x24 native size |
| Toasts | Sonner | Native-feel toasts, promise API, action buttons |
| Dialogs/Sheets | Radix Dialog + Vaul (drawer) | Bottom sheets with drag-to-dismiss, keyboard avoidance |
Why not a pre-built component library (shadcn, MUI, etc.)? Pre-built libraries impose opinions on styling, animation curves, and component APIs that fight native feel. We need pixel-perfect control over every transition, spring curve, and touch response. Radix + Tailwind + Motion gives us that control with accessibility built in.
Animation Specs (Platform-Matched)
// src/lib/animation/specs.ts
export const animationSpecs = {
// iOS-style spring (used for modals, sheets, navigation)
iosSpring: { type: 'spring', stiffness: 500, damping: 30, mass: 1 },
// Android Material motion (used for FAB, chips, dialogs)
materialStandard: { type: 'spring', stiffness: 280, damping: 40, mass: 1 },
materialEmphasized: { type: 'spring', stiffness: 200, damping: 20, mass: 1 },
// Quick micro-interactions (buttons, toggles, favorites)
microInteraction: { type: 'spring', stiffness: 400, damping: 25, mass: 0.5 },
// Page transitions
pageEnter: { type: 'spring', stiffness: 300, damping: 35, mass: 1 },
pageExit: { type: 'tween', duration: 0.15, ease: [0.4, 0, 0.2, 1] },
// Bottom sheet (Vaul style)
sheetEnter: { type: 'spring', stiffness: 400, damping: 40, mass: 1 },
sheetExit: { type: 'spring', stiffness: 400, damping: 50, mass: 1 },
// Frequency number morph
frequencyMorph: { type: 'spring', stiffness: 600, damping: 40, mass: 1 },
};Touch Target Requirements
All interactive elements must meet minimum touch target sizes:
| Element Type | Minimum Size | Recommended | Notes |
|---|---|---|---|
| Primary Actions (Play, Favorite, Tune) | 48x48dp | 56x56dp | Fitts's Law: larger = faster |
| Secondary Actions (Share, Menu, Info) | 44x44dp | 48x48dp | iOS HIG minimum |
| List Items (Station cards) | 48dp height | 64dp height | Full-width tap area |
| Sliders/Controls (Volume, Seek) | 44dp touch zone | 48dp | Thumb + track |
| Close/Dismiss | 44x44dp | 48x48dp | Easy to hit |
Spacing between targets: Minimum 8dp (12dp recommended) to prevent mis-taps.
Implementation:
/* src/styles/touch-targets.css */
.touch-target {
min-width: 48px;
min-height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.touch-target-primary {
min-width: 56px;
min-height: 56px;
}
.touch-target-list {
min-height: 64px;
}
/* Ensure tap highlight on iOS */
.touch-target {
-webkit-tap-highlight-color: transparent;
}
.touch-target:active {
background: var(--color-bg-hover);
}UI Design: impeccable.style
Design system: https://impeccable.style/#downloads
Color Palette (Radio-Optimized)
/* src/styles/variables.css */
:root {
/* Base - true black for OLED, high contrast */
--color-bg: #0A0A0A;
--color-bg-elevated: #141414;
--color-bg-hover: #1E1E1E;
/* Text */
--color-fg: #FAFAFA;
--color-fg-muted: #A3A3A3;
--color-fg-subtle: #737373;
/* Accent - IFM Orange (radio dial) */
--color-accent: #FF6B00;
--color-accent-hover: #FF8533;
--color-accent-muted: #FF6B0033;
/* Semantic */
--color-live: #FF3B30; /* LIVE indicator */
--color-connected: #34C759; /* Connected */
--color-warning: #FF9F0A;
--color-error: #FF453A;
/* Surfaces */
--color-card: #141414;
--color-card-border: #2A2A2A;
--color-input: #1E1E1E;
--color-input-border: #3A3A3A;
/* Spacing (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 72px; /* Frequency display */
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 350ms ease;
}Key Components
Frequency Display (Hero)
// src/components/Player/FrequencyDisplay.tsx
export function FrequencyDisplay({ frequency, name, isLive, signalStrength }) {
return (
<div className="frequency-display">
<div className="frequency-main">
<span className="frequency-number">{frequency}</span>
{name && <span className="frequency-name">{name}</span>}
</div>
<div className="frequency-status">
<SignalBars strength={signalStrength} />
{isLive && <LiveBadge />}
</div>
</div>
);
}.frequency-display {
text-align: center;
padding: var(--space-8) var(--space-4);
}
.frequency-number {
font-family: var(--font-mono);
font-size: var(--text-4xl);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-fg);
line-height: 1;
}
.frequency-name {
display: block;
font-size: var(--text-lg);
font-weight: 400;
color: var(--color-fg-muted);
margin-top: var(--space-2);
text-transform: lowercase;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-live);
color: white;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--radius-full);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}Station Card (Discover)
// src/components/Discover/StationCard.tsx
export function StationCard({ station, isFavorite, onToggleFavorite, onPress }) {
return (
<button className="station-card" onClick={onPress}>
<div className="station-info">
<div className="station-frequency">
<span className="freq-number">{station.frequency}</span>
{station.name && <span className="freq-name">{station.name}</span>}
</div>
<div className="station-meta">
<span className="category">{station.category}</span>
<span className="listeners">{station.listenerCount} 👥</span>
<span className="signal">{station.signalStrength}%</span>
</div>
</div>
<div className="station-actions">
{station.accessType === 'protected' && <LockIcon />}
<FavoriteButton starred={isFavorite} onClick={onToggleFavorite} />
<PlayButton />
</div>
</button>
);
}Chat Panel (Slide-up)
// src/components/Chat/ChatPanel.tsx
export function ChatPanel({ frequency, messages, onSend, onClose }) {
return (
<div className="chat-panel" role="dialog">
<div className="chat-header">
<h3>Chat — {frequency}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="chat-messages">
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<ChatInput onSend={onSend} />
</div>
);
}Architecture
Project Structure
packages/ifm-pwa/
├── public/
│ ├── manifest.json # PWA manifest (auto-generated)
│ ├── icons/ # PWA icons (72–512px, maskable)
│ │ ├── icon-72.png
│ │ ├── icon-192.png
│ │ ├── icon-512.png
│ │ └── maskable-512.png
│ └── sw.js # Service Worker (auto-generated)
├── src/
│ ├── main.tsx # Entry, register SW, init media session
│ ├── App.tsx # Routing, providers
│ ├── components/
│ │ ├── Discover/ # Station list, search, categories
│ │ ├── Player/ # Big frequency, controls, artwork
│ │ ├── Chat/ # Chat panel (slide-up)
│ │ ├── Files/ # Received files
│ │ ├── Favorites/ # Starred stations
│ │ ├── ProtectedKey/ # Key entry modal
│ │ ├── InstallPrompt/ # "Add to Home Screen" button
│ │ ├── ShareDialog/ # Web Share + QR code
│ │ └── Header/ # Brand, search, menu
│ ├── hooks/
│ │ ├── useStation.ts # Tuning, playback state
│ │ ├── useDiscovery.ts # Station list (mDNS via WS + DHT)
│ │ ├── useFavorites.ts # IndexedDB persistence
│ │ ├── useMediaSession.ts # Lock screen controls
│ │ ├── useWakeLock.ts # Prevent sleep during playback
│ │ ├── useBackgroundAudio.ts # AudioContext lifecycle
│ │ ├── usePushNotifications.ts # Web Push subscription
│ │ ├── useKeychain.ts # Protected keys (IndexedDB + Web Crypto)
│ │ └── useInstallPrompt.ts # PWA install UX
│ ├── lib/
│ │ ├── ifm.ts # @ifm/sdk-wasm wrapper
│ │ ├── audio.ts # Opus decode, AudioContext, MediaSource
│ │ ├── indexeddb.ts # Station cache, keys, history
│ │ ├── push.ts # VAPID push subscription
│ │ ├── qrcode.ts # QR code generation
│ │ └── animation/
│ │ └── specs.ts # Platform-matched animation specs
│ ├── styles/
│ │ ├── globals.css # impeccable.style tokens + globals
│ │ ├── variables.css # CSS custom properties
│ │ ├── player.css # Big frequency display
│ │ ├── components.css # Component styles
│ │ └── touch-targets.css # Touch target utilities
│ └── types/
│ ├── station.ts
│ ├── events.ts
│ └── pwa.ts
├── vite.config.ts # Vite + PWA config
├── package.json
├── tsconfig.json
└── wrangler.toml # Cloudflare Pages deploy config (optional)PWA Configuration
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'icons/*.png'],
manifest: {
name: 'IFM Radio Listener',
short_name: 'IFM Radio',
description: 'Decentralized radio. Tune in. Listen. No accounts.',
theme_color: '#0A0A0A',
background_color: '#0A0A0A',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{ src: '/icons/icon-72.png', sizes: '72x72', type: 'image/png', purpose: 'any' },
{ 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: 'Favorites', url: '/#favorites', icons: [{ src: '/icons/star.png', sizes: '96x96' }] },
{ name: 'Search', url: '/#search', icons: [{ src: '/icons/search.png', sizes: '96x96' }] },
],
screenshots: [
{ src: '/screenshots/discover.png', sizes: '1284x2778', type: 'image/png', form_factor: 'narrow' },
{ src: '/screenshots/player.png', sizes: '1284x2778', type: 'image/png', form_factor: 'narrow' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,wasm}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/ifm\.sh\/api\/stations/,
handler: 'NetworkFirst',
options: {
cacheName: 'stations-api',
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }, // 24h
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /^https:\/\/bootstrap\.ifm\.sh/,
handler: 'CacheFirst',
options: {
cacheName: 'bootstrap-peers',
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 7 }, // 7d
},
},
],
},
devOptions: {
enabled: true,
type: 'module',
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
target: 'esnext',
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
'ifm-sdk': ['@ifm/sdk-wasm'],
'opus': ['opus-wasm'],
},
},
},
},
server: {
port: 5177,
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
});manifest.json (auto-generated)
{
"name": "IFM Radio Listener",
"short_name": "IFM Radio",
"description": "Decentralized radio. Tune in. Listen. No accounts.",
"theme_color": "#0A0A0A",
"background_color": "#0A0A0A",
"display": "standalone",
"orientation": "portrait-primary",
"scope": "/",
"start_url": "/",
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
{ "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": "Favorites", "url": "/#favorites" },
{ "name": "Search", "url": "/#search" }
]
}Key Implementation Details
Background Audio (Critical)
// src/hooks/useBackgroundAudio.ts
import { useEffect, useRef, useCallback } from 'react';
import { IFM, AudioChunk } from '@ifm/sdk-wasm';
export function useBackgroundAudio() {
const audioContextRef = useRef<AudioContext | null>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const opusDecoderRef = useRef<OpusDecoder | null>(null);
const initSegmentRef = useRef<Uint8Array | null>(null);
const isPlayingRef = useRef(false);
// Initialize AudioContext on first user gesture
const ensureAudioContext = useCallback(async () => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext({
sampleRate: 48000,
latencyHint: 'playback'
});
}
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
return audioContextRef.current;
}, []);
// ... rest of implementation
}