Skip to content

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.

PrincipleImplementation
Radio metaphorBig frequency knob, instant tune, live indicator
Zero frictionOpen URL → listening in < 2s. No login, no account, no permissions until needed
Works like radioBackground playback on lock screen, media controls, wake lock
Offline-capableCached station list, cached init segment = instant replay on reconnect
Native feelPWA: "Add to Home Screen" → full-screen app, no browser chrome
Built on protocol100% @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

FeatureDescription
Live station discoverymDNS (local) + DHT (internet) via @ifm/sdk-wasm
Instant tuneCached WebM init segment → audio in < 200ms
Frequency displayLarge FM-style: 91.700 with optional name music.lofi
Signal qualityVisual bars: peer/relay/origin + RTT
CategoriesMusic, Talk, News, Sports, Community, Custom
SearchReal-time filter by number, name, category

Playback

FeatureDescription
Background audioContinues on lock screen, app switch, home screen
Media Session APILock screen controls (play/pause, next/prev, seek)
Wake LockOptional: keeps screen on during playback
VolumeSystem volume + in-app slider
Auto-reconnectNetwork change → seamless re-tune
Mid-stream joinCached init segment = join live stream instantly

Social

FeatureDescription
ChatReal-time text with station + listeners (slide-up panel)
FilesReceive files broadcast by station → Downloads/IFM/
ReactionsQuick emoji: 👍 ❤️ 🔥 😂
Share stationWeb Share API → native sheet; QR code fallback

Favorites & History

FeatureDescription
FavoritesStar stations → pinned top, persists (IndexedDB)
RecentLast 20 played, timestamps
CategoriesAuto-grouped by station category

PWA Features

FeatureDescription
Installable"Add to Home Screen" → standalone app
OfflineStation list cached 24h; init segment cached
Push notifications"Station X is now live" (opt-in)
Auto-updateSW updates on refresh; prompt to reload
Deep linkshttps://ifm.sh/pwa#91.700 auto-tunes
Web Share TargetReceive 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)

MetricTargetMeasurement
TTI (Time to Interactive)< 1.5s on 4GLighthouse / WebPageTest
First Contentful Paint< 800msChrome DevTools
Audio Start Latency< 200ms (cached) / < 800ms (cold)Custom metric
Tap-to-Response< 100msperformance.now()
Frame Rate60fps sustainedDevTools Performance
Bundle Size (gzipped)< 150KB initial / < 500KB totalVite bundle analyzer
WASM Init< 300ms@ifm/sdk-wasm load time
Memory (idle)< 50MBDevTools Memory
Memory (playing)< 100MBDevTools Memory

UI/UX Native Parity Checklist

iOS Safari / iOS PWA Specific

  • [ ] Safe Area Insetsenv(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 Feedbacknavigator.vibrate() for key actions (tune, favorite, play)
  • [ ] Status Bartheme-color + apple-mobile-web-app-status-bar-style: black-translucent
  • [ ] Launch Screenapple-touch-startup-image for splash screen
  • [ ] Orientation Lockorientation: '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-Edgedisplay: '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_target in manifest)

Cross-Platform Native Behaviors

  • [ ] 60fps Animations — All transitions use transform/opacity only (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

LayerChoiceRationale
UI PrimitivesRadix UI (headless)Unstyled, accessible, full control over styling/animation
AnimationFramer Motion (motion package)Spring physics, layout animations, gesture handling, 60fps
StylingTailwindCSS v4 + CSS VariablesDesign tokens as CSS vars, JIT, zero runtime, dark mode native
StateTanStack Query + ZustandServer state + client state, devtools, persistence
RoutingTanStack RouterType-safe, file-based, loader patterns, suspense
FormsReact Hook Form + ZodPerformant, validation, native-feel inputs
GesturesFramer Motion + @use-gesture/reactDrag, swipe, pinch, pan — native physics
Virtualization@tanstack/react-virtual60fps lists with 10k+ items
IconsLucide ReactConsistent, tree-shakable, 24x24 native size
ToastsSonnerNative-feel toasts, promise API, action buttons
Dialogs/SheetsRadix 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)

typescript
// 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 TypeMinimum SizeRecommendedNotes
Primary Actions (Play, Favorite, Tune)48x48dp56x56dpFitts's Law: larger = faster
Secondary Actions (Share, Menu, Info)44x44dp48x48dpiOS HIG minimum
List Items (Station cards)48dp height64dp heightFull-width tap area
Sliders/Controls (Volume, Seek)44dp touch zone48dpThumb + track
Close/Dismiss44x44dp48x48dpEasy to hit

Spacing between targets: Minimum 8dp (12dp recommended) to prevent mis-taps.

Implementation:

css
/* 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)

css
/* 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)

tsx
// 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>
  );
}
css
.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)

tsx
// 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)

tsx
// 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

typescript
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)

json
{
  "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)

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

References

Released under the MIT License.