Skip to content

IFM Mobile PWA — Architecture

Progressive Web App for mobile listeners. Radio-like experience, works offline, background audio on lock screen, zero-install. Built exclusively with IFM Protocol products (SDK + WASM).


Overview

PropertyValue
NameIFM Mobile Radio Listener
Packagepackages/ifm-pwa/
Core@ifm/sdk-wasm (WASM build of ifm-core)
UIReact + TypeScript (impeccable.style design system)
PWA FeaturesService Worker, Background Sync, Push Notifications, Offline Cache
PlatformsiOS Safari, Android Chrome, Desktop browsers
Installhttps://ifm.sh/pwa → "Add to Home Screen"
No LoginAnonymous by default; identity is cryptographic (Ed25519)

Architecture


Technology Stack

Core

  • @ifm/sdk-wasm — WASM build of ifm-core (via wasm-bindgen + wasm-pack)
  • Protocol — Full IFM protocol in browser: DHT, GossipSub, QUIC (via WebRTC data channels)
  • Audio — Opus decoding via opus-wasm; playback via AudioContext + MediaSource or AudioWorklet

UI Framework

  • React 19 + TypeScript
  • impeccable.style — Design system (https://impeccable.style/#downloads)
    • Color tokens, spacing, typography
    • Radio-optimized components: large touch targets, high contrast
  • Vite — Build tool, PWA plugin (vite-plugin-pwa)

PWA Features

FeatureImplementation
Service Workervite-plugin-pwa → Workbox; cache-first for assets, network-first for station list
OfflineIndexedDB caches station metadata, last-played audio init segment
Background AudioAudioContext + MediaSession API; Wake Lock API prevents sleep
Push NotificationsWeb Push API + VAPID; "Station X is live"
Install Promptbeforeinstallprompt event → custom "Add to Home Screen" button
Web ShareShare station via navigator.share() → QR code fallback
Deep Linkshttps://ifm.sh/pwa#91.700 → auto-tune on load

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
│   ├── styles/
│   │   ├── globals.css        # impeccable.style tokens + globals
│   │   ├── variables.css      # CSS custom properties
│   │   ├── player.css         # Big frequency display
│   │   └── components.css     # Component styles
│   └── 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'
      });
      
      // Resume if suspended (autoplay policy)
      if (audioContextRef.current.state === 'suspended') {
        await audioContextRef.current.resume();
      }
    }
    return audioContextRef.current;
  }, []);

  // Initialize MediaSource for streaming Opus/WebM
  const initMediaSource = useCallback(async () => {
    const ctx = await ensureAudioContext();
    
    mediaSourceRef.current = new MediaSource();
    const audio = new Audio();
    audio.src = URL.createObjectURL(mediaSourceRef.current);
    audio.crossOrigin = 'anonymous';
    
    // Handle MediaSource events
    mediaSourceRef.current.addEventListener('sourceopen', () => {
      sourceBufferRef.current = mediaSourceRef.current!.addSourceBuffer('audio/webm; codecs="opus"');
      sourceBufferRef.current.mode = 'segments';
      
      // Feed cached init segment if available
      if (initSegmentRef.current) {
        sourceBufferRef.current.appendBuffer(initSegmentRef.current);
      }
    });

    return audio;
  }, [ensureAudioContext]);

  // Decode Opus chunk and feed to SourceBuffer
  const feedAudioChunk = useCallback(async (chunk: AudioChunk) => {
    if (!sourceBufferRef.current || sourceBufferRef.current.updating) return;
    
    try {
      // First chunk = init segment (WebM header)
      if (chunk.isInitSegment) {
        initSegmentRef.current = chunk.data;
        if (sourceBufferRef.current && !sourceBufferRef.current.updating) {
          sourceBufferRef.current.appendBuffer(chunk.data);
        }
        return;
      }
      
      // Regular audio chunk
      if (sourceBufferRef.current && !sourceBufferRef.current.updating) {
        sourceBufferRef.current.appendBuffer(chunk.data);
      }
    } catch (e) {
      console.error('Audio feed error:', e);
      // Recover: re-init MediaSource
      await initMediaSource();
    }
  }, [initMediaSource]);

  // Play/pause
  const play = useCallback(async () => {
    const audio = await initMediaSource();
    await audio.play();
    isPlayingRef.current = true;
    updateMediaSession({ playing: true });
  }, [initMediaSource]);

  const pause = useCallback(() => {
    // Audio element pause handled via media session
    isPlayingRef.current = false;
    updateMediaSession({ playing: false });
  }, []);

  return { play, pause, feedAudioChunk, isPlaying: isPlayingRef.current };
}

Media Session (Lock Screen Controls)

typescript
// src/hooks/useMediaSession.ts
import { useEffect } from 'react';

export function useMediaSession(station: Station | null, isPlaying: boolean, position: number, duration: number) {
  useEffect(() => {
    if (!('mediaSession' in navigator)) return;

    if (station) {
      navigator.mediaSession.metadata = new MediaMetadata({
        title: station.name || station.frequency,
        artist: 'IFM Radio',
        album: station.category || 'Live',
        artwork: station.artworkUrl ? [
          { src: station.artworkUrl, sizes: '512x512', type: 'image/png' }
        ] : [],
      });

      navigator.mediaSession.setActionHandler('play', () => { /* handled by app */ });
      navigator.mediaSession.setActionHandler('pause', () => { /* handled by app */ });
      navigator.mediaSession.setActionHandler('previoustrack', () => { /* previous favorite */ });
      navigator.mediaSession.setActionHandler('nexttrack', () => { /* next favorite */ });
      navigator.mediaSession.setActionHandler('seekto', (details) => { /* seek */ });
    } else {
      navigator.mediaSession.metadata = null;
    }

    navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
    
    if (isPlaying && duration > 0) {
      navigator.mediaSession.setPositionState({
        duration,
        position,
        playbackRate: 1,
      });
    }
  }, [station, isPlaying, position, duration]);
}

Wake Lock (Prevent Sleep)

typescript
// src/hooks/useWakeLock.ts
import { useEffect, useRef } from 'react';

export function useWakeLock(isPlaying: boolean) {
  const wakeLockRef = useRef<WakeLockSentinel | null>(null);

  useEffect(() => {
    const requestWakeLock = async () => {
      if (!isPlaying) {
        wakeLockRef.current?.release();
        wakeLockRef.current = null;
        return;
      }
      
      try {
        if (!wakeLockRef.current) {
          wakeLockRef.current = await navigator.wakeLock.request('screen');
          wakeLockRef.current.addEventListener('release', () => {
            wakeLockRef.current = null;
          });
        }
      } catch (e) {
        console.warn('Wake Lock failed:', e);
      }
    };

    requestWakeLock();
    return () => wakeLockRef.current?.release();
  }, [isPlaying]);
}

Service Worker (Offline + Push)

typescript
// public/sw.js (generated by vite-plugin-pwa, extended)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';

declare const self: ServiceWorkerGlobalScope;

// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// Navigation: network first, fallback to offline page
registerRoute(
  new NavigationRoute(
    new NetworkFirst({
      cacheName: 'pages',
      plugins: [new ExpirationPlugin({ maxEntries: 50 })],
    })
  )
);

// Station API: network first, 24h cache
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/stations'),
  new NetworkFirst({
    cacheName: 'stations-api',
    plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 86400 })],
  })
);

// Bootstrap peers: cache first, 7d
registerRoute(
  ({ url }) => url.hostname.includes('bootstrap.ifm.sh'),
  new CacheFirst({
    cacheName: 'bootstrap-peers',
    plugins: [new ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 604800 })],
  })
);

// Background sync for offline actions (chat messages, favorites)
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions', {
  maxRetentionTime: 24 * 60, // 24 hours
});

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/') && ['POST', 'PUT', 'DELETE'].includes(self.request.method),
  new NetworkFirst({
    plugins: [bgSyncPlugin],
  })
);

// Push notifications
self.addEventListener('push', (event) => {
  if (!event.data) return;
  
  const data = event.data.json();
  const options: NotificationOptions = {
    body: data.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    tag: data.tag || 'ifm-notification',
    data: { url: data.url || '/' },
    actions: [
      { action: 'open', title: 'Open' },
      { action: 'dismiss', title: 'Dismiss' },
    ],
    requireInteraction: true,
  };
  
  event.waitUntil(self.registration.showNotification(data.title, options));
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.action === 'open' || !event.action) {
    const url = event.notification.data?.url || '/';
    event.waitUntil(clients.openWindow(url));
  }
});

IndexedDB (Offline Cache + Keys)

typescript
// src/lib/indexeddb.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';

interface IfmDB extends DBSchema {
  stations: {
    key: string; // frequency
    value: StationCache;
    indexes: { 'by-category': string; 'by-last-played': number };
  };
  favorites: {
    key: string; // frequency
    value: Favorite;
  };
  protectedKeys: {
    key: string; // frequency
    value: { key: string; added: number }; // encrypted key
  };
  history: {
    key: number; // auto-increment
    value: HistoryEntry;
    indexes: { 'by-date': number };
  };
  chatMessages: {
    key: string; // frequency
    value: ChatMessage[];
  };
}

let dbPromise: Promise<IDBPDatabase<IfmDB>>;

export function getDB() {
  if (!dbPromise) {
    dbPromise = openDB<IfmDB>('ifm-pwa', 1, {
      upgrade(db) {
        db.createObjectStore('stations', { keyPath: 'frequency' })
          .createIndex('by-category', 'category')
          .createIndex('by-last-played', 'lastPlayed');
        db.createObjectStore('favorites', { keyPath: 'frequency' });
        db.createObjectStore('protectedKeys', { keyPath: 'frequency' });
        db.createObjectStore('history', { keyPath: 'id', autoIncrement: true })
          .createIndex('by-date', 'timestamp');
        db.createObjectStore('chatMessages', { keyPath: 'frequency' });
      },
    });
  }
  return dbPromise;
}

// Encrypt protected keys with Web Crypto (derived from user's identity)
export async function encryptKey(key: string): Promise<string> {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(await getIdentityKey()), // User's Ed25519 private key
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );
  
  const derivedKey = await crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt: encoder.encode('ifm-protected-key'), iterations: 100000, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
  
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    derivedKey,
    encoder.encode(key)
  );
  
  // Store iv + encrypted together
  const combined = new Uint8Array(iv.length + encrypted.byteLength);
  combined.set(iv);
  combined.set(new Uint8Array(encrypted), iv.length);
  
  return btoa(String.fromCharCode(...combined));
}

Discovery (mDNS via WebSocket + DHT)

typescript
// src/hooks/useDiscovery.ts
import { useEffect, useState } from 'react';
import { IFM } from '@ifm/sdk-wasm';

export function useDiscovery() {
  const [stations, setStations] = useState<Station[]>([]);
  const [loading, setLoading] = useState(true);
  const ifmRef = useRef<IFM | null>(null);

  useEffect(() => {
    let mounted = true;

    const init = async () => {
      const ifm = await IFM.create({
        transport: 'webrtc', // Browser: WebRTC data channels
        bootstrapPeers: [
          '/ip4/bootstrap.ifm.sh/tcp/8790/wss/p2p/12D3KooW...',
          '/ip4/bootstrap2.ifm.sh/tcp/8790/wss/p2p/12D3KooW...',
        ],
      });

      // Subscribe to frequency announcements
      ifm.on('frequency:announce', (announcement) => {
        if (!mounted) return;
        setStations(prev => {
          const idx = prev.findIndex(s => s.frequency === announcement.frequency);
          const station = announcement.toStation();
          if (idx >= 0) {
            const next = [...prev];
            next[idx] = station;
            return next;
          }
          return [...prev, station];
        });
      });

      ifm.on('frequency:withdraw', (frequency) => {
        if (!mounted) return;
        setStations(prev => prev.filter(s => s.frequency !== frequency));
      });

      // Initial scan
      const discovered = await ifm.scan();
      if (mounted) setStations(discovered.map(a => a.toStation()));

      ifmRef.current = ifm;
      setLoading(false);
    };

    init();

    return () => {
      mounted = false;
      ifmRef.current?.disconnect();
    };
  }, []);

  const refresh = async () => {
    ifmRef.current?.scan().then(s => setStations(s.map(a => a.toStation())));
  };

  return { stations, loading, refresh };
}

Build & Deploy

Development

bash
cd packages/ifm-pwa

# Install deps
bun install

# Dev server (with PWA features)
bun run dev
# → http://localhost:5177

# Test PWA features in dev
# Chrome DevTools → Application → Service Workers → "Update on reload"
# Chrome DevTools → Application → Manifest → "Add to Home Screen"

Build

bash
# Production build
bun run build
# Output: dist/

# Preview production build
bun run preview
# → http://localhost:4173

Deploy Targets

TargetConfigURL
Cloudflare Pageswrangler.toml + GitHub Actionshttps://ifm.sh/pwa
Netlifynetlify.toml + GitHub Actionshttps://ifm-radio.netlify.app
GitHub Pages.github/workflows/pages.ymlhttps://ifm.github.io/ifm-pwa
Vercelvercel.json + GitHub Actionshttps://ifm-pwa.vercel.app
CDNcdn.jsdelivr.net/gh/ifm/ifm-pwa@latesthttps://cdn.jsdelivr.net/gh/ifm/ifm-pwa@latest

Cloudflare Pages Config (wrangler.toml)

toml
name = "ifm-pwa"
compatibility_date = "2024-01-15"
pages_build_output_dir = "dist"

[build]
command = "bun run build"

[[routes]]
pattern = "ifm.sh/pwa/*"
zone_name = "ifm.sh"

[env.production]
VAPID_PUBLIC_KEY = "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U"
VAPID_PRIVATE_KEY = "****" # Secret in Cloudflare dashboard

Release Artifacts

dist/
├── index.html              # Entry point
├── manifest.json           # PWA manifest
├── sw.js                   # Service Worker
├── assets/
│   ├── index-<hash>.js     # Main bundle (~200 KB gzipped)
│   ├── index-<hash>.css    # Styles (~30 KB gzipped)
│   ├── ifm-sdk-<hash>.js   # WASM glue (~50 KB gzipped)
│   └── ifm-sdk_bg-<hash>.wasm  # WASM binary (~400 KB gzipped)
├── icons/
│   ├── icon-72.png
│   ├── icon-192.png
│   ├── icon-512.png
│   └── maskable-512.png
├── stations.json           # Cached station index (updated via SW)
└── version.json            # { "version": "1.3.0", "build": "20241215.0300" }

Testing Checklist

PWA Criteria (Lighthouse)

  • [ ] Installable — Manifest, icons, HTTPS, Service Worker
  • [ ] Offline — Caches station list, plays cached init segment
  • [ ] Fast — < 3s TTI on 3G; WASM streaming compile
  • [ ] Accessible — Semantic HTML, contrast, touch targets 48dp+
  • [ ] Best Practices — No console errors, CSP, secure headers

Mobile-Specific

  • [ ] iOS Safari — "Add to Home Screen" works; background audio persists on lock
  • [ ] Android Chrome — Install prompt; media session on lock screen
  • [ ] Background audio — Plays 30+ min with screen locked
  • [ ] Wake Lock — Screen stays on during playback (optional)
  • [ ] Push notifications — VAPID subscription; "Station live" alerts
  • [ ] Web Share — Share station → native share sheet
  • [ ] Deep linkshttps://ifm.sh/pwa#91.700 auto-tunes

Released under the MIT License.