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
| Property | Value |
|---|---|
| Name | IFM Mobile Radio Listener |
| Package | packages/ifm-pwa/ |
| Core | @ifm/sdk-wasm (WASM build of ifm-core) |
| UI | React + TypeScript (impeccable.style design system) |
| PWA Features | Service Worker, Background Sync, Push Notifications, Offline Cache |
| Platforms | iOS Safari, Android Chrome, Desktop browsers |
| Install | https://ifm.sh/pwa → "Add to Home Screen" |
| No Login | Anonymous by default; identity is cryptographic (Ed25519) |
Architecture
Technology Stack
Core
@ifm/sdk-wasm— WASM build ofifm-core(viawasm-bindgen+wasm-pack)- Protocol — Full IFM protocol in browser: DHT, GossipSub, QUIC (via WebRTC data channels)
- Audio — Opus decoding via
opus-wasm; playback viaAudioContext+MediaSourceorAudioWorklet
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
| Feature | Implementation |
|---|---|
| Service Worker | vite-plugin-pwa → Workbox; cache-first for assets, network-first for station list |
| Offline | IndexedDB caches station metadata, last-played audio init segment |
| Background Audio | AudioContext + MediaSession API; Wake Lock API prevents sleep |
| Push Notifications | Web Push API + VAPID; "Station X is live" |
| Install Prompt | beforeinstallprompt event → custom "Add to Home Screen" button |
| Web Share | Share station via navigator.share() → QR code fallback |
| Deep Links | https://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:4173Deploy Targets
| Target | Config | URL |
|---|---|---|
| Cloudflare Pages | wrangler.toml + GitHub Actions | https://ifm.sh/pwa |
| Netlify | netlify.toml + GitHub Actions | https://ifm-radio.netlify.app |
| GitHub Pages | .github/workflows/pages.yml | https://ifm.github.io/ifm-pwa |
| Vercel | vercel.json + GitHub Actions | https://ifm-pwa.vercel.app |
| CDN | cdn.jsdelivr.net/gh/ifm/ifm-pwa@latest | https://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 dashboardRelease 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 links —
https://ifm.sh/pwa#91.700auto-tunes
Related
- IFM Mobile Radio Listener — Product documentation
- PWA Features Deep Dive — Background audio, offline, impeccable.style UI
- Desktop Apps - Radio Listener — Native counterpart
- Distribution Pipeline