IFM PWA Features — Deep Dive
Technical implementation of background audio, offline support, and impeccable.style UI for the IFM Mobile Radio Listener.
Background Audio
The Challenge
Mobile browsers aggressively throttle background tabs and stop audio when:
- Screen locks
- User switches apps
- Tab is backgrounded > 30s (iOS) / immediately (Android in some cases)
Solution: Multi-Layer Approach
1. Media Session API (Lock Screen Controls)
typescript
// src/hooks/useMediaSession.ts
export function useMediaSession(
station: Station | null,
state: 'playing' | 'paused' | 'buffering',
position: number,
duration: number
) {
useEffect(() => {
if (!('mediaSession' in navigator)) return;
// Metadata (shown on lock screen)
if (station) {
navigator.mediaSession.metadata = new MediaMetadata({
title: station.name || station.frequency,
artist: 'IFM Radio',
album: station.category || 'Live Broadcast',
artwork: station.artworkUrl
? [{ src: station.artworkUrl, sizes: '512x512', type: 'image/png' }]
: [],
});
} else {
navigator.mediaSession.metadata = null;
}
// Playback state
navigator.mediaSession.playbackState = state;
// Position (for seek bar on lock screen)
if (duration > 0) {
navigator.mediaSession.setPositionState({
duration,
position,
playbackRate: 1,
});
}
// Action handlers (lock screen buttons)
const handlers: MediaSessionActionHandler[] = [
['play', () => dispatch({ type: 'PLAY' })],
['pause', () => dispatch({ type: 'PAUSE' })],
['previoustrack', () => dispatch({ type: 'PREV_FAVORITE' })],
['nexttrack', () => dispatch({ type: 'NEXT_FAVORITE' })],
['seekto', (details) => dispatch({ type: 'SEEK', position: details.seekTime })],
['seekbackward', (details) => dispatch({ type: 'SEEK_BACK', seconds: details.seekOffset || 10 })],
['seekforward', (details) => dispatch({ type: 'SEEK_FWD', seconds: details.seekOffset || 10 })],
];
handlers.forEach(([action, handler]) => {
try {
navigator.mediaSession.setActionHandler(action, handler);
} catch {
// Not supported
}
});
return () => {
handlers.forEach(([action]) => {
navigator.mediaSession.setActionHandler(action, null);
});
};
}, [station, state, position, duration]);
}Platform Notes:
- iOS 16.4+: Full support including artwork
- Android Chrome: Full support; artwork via
MediaMetadata - Desktop: Works on Chrome/Edge/Firefox/Safari
2. AudioContext + MediaSource (Uninterrupted Playback)
typescript
// src/lib/audio/BackgroundAudioPlayer.ts
export class BackgroundAudioPlayer {
private audioContext: AudioContext | null = null;
private mediaSource: MediaSource | null = null;
private sourceBuffer: SourceBuffer | null = null;
private audioElement: HTMLAudioElement | null = null;
private initSegment: Uint8Array | null = null;
private pendingChunks: Uint8Array[] = [];
private isPlaying = false;
async initialize(): Promise<HTMLAudioElement> {
// Create AudioContext (must be user gesture)
this.audioContext = new AudioContext({
sampleRate: 48000,
latencyHint: 'playback',
});
// MediaSource for streaming Opus/WebM
this.mediaSource = new MediaSource();
this.audioElement = new Audio();
this.audioElement.src = URL.createObjectURL(this.mediaSource);
this.audioElement.crossOrigin = 'anonymous';
this.audioElement.preload = 'none';
this.mediaSource.addEventListener('sourceopen', () => this.onSourceOpen());
this.mediaSource.addEventListener('sourceended', () => this.onSourceEnded());
this.mediaSource.addEventListener('sourceclose', () => this.onSourceClose());
// Handle audio events
this.audioElement.addEventListener('play', () => { this.isPlaying = true; this.updateMediaSession(); });
this.audioElement.addEventListener('pause', () => { this.isPlaying = false; this.updateMediaSession(); });
this.audioElement.addEventListener('ended', () => this.onEnded());
this.audioElement.addEventListener('error', (e) => this.onError(e));
return this.audioElement;
}
private onSourceOpen() {
if (!this.mediaSource) return;
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"');
this.sourceBuffer.mode = 'segments';
this.sourceBuffer.addEventListener('updateend', () => this.onBufferUpdateEnd());
this.sourceBuffer.addEventListener('error', (e) => this.onBufferError(e));
// Feed cached init segment
if (this.initSegment) {
this.appendBuffer(this.initSegment);
}
// Feed pending chunks
while (this.pendingChunks.length && this.sourceBuffer && !this.sourceBuffer.updating) {
this.appendBuffer(this.pendingChunks.shift()!);
}
}
private onBufferUpdateEnd() {
if (!this.sourceBuffer || this.sourceBuffer.updating) return;
if (this.pendingChunks.length) {
this.appendBuffer(this.pendingChunks.shift()!);
}
}
appendChunk(chunk: Uint8Array, isInitSegment = false) {
if (isInitSegment) {
this.initSegment = chunk;
if (this.sourceBuffer && !this.sourceBuffer.updating) {
this.appendBuffer(chunk);
}
return;
}
if (this.sourceBuffer && !this.sourceBuffer.updating) {
this.appendBuffer(chunk);
} else {
this.pendingChunks.push(chunk);
}
}
private appendBuffer(chunk: Uint8Array) {
try {
this.sourceBuffer?.appendBuffer(chunk);
} catch (e) {
console.error('Append buffer failed:', e);
this.recover();
}
}
private recover() {
// Reinitialize MediaSource on fatal error
this.cleanup();
this.initialize().then(el => {
if (this.initSegment) this.appendChunk(this.initSegment, true);
this.pendingChunks.forEach(c => this.appendChunk(c));
if (this.isPlaying) el.play();
});
}
async play(): Promise<void> {
if (!this.audioElement) await this.initialize();
await this.audioElement!.play();
this.isPlaying = true;
}
pause(): void {
this.audioElement?.pause();
this.isPlaying = false;
}
private updateMediaSession() {
// Dispatch custom event for MediaSession hook
window.dispatchEvent(new CustomEvent('ifm:audio-state', {
detail: { playing: this.isPlaying }
}));
}
cleanup() {
this.audioElement?.pause();
URL.revokeObjectURL(this.audioElement?.src || '');
this.mediaSource = null;
this.sourceBuffer = null;
this.audioElement = null;
this.pendingChunks = [];
}
}3. Wake Lock API (Prevent Sleep)
typescript
// src/hooks/useWakeLock.ts
export function useWakeLock(enabled: boolean, isPlaying: boolean) {
const sentinelRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!enabled || !isPlaying) {
sentinelRef.current?.release();
sentinelRef.current = null;
return;
}
const request = async () => {
try {
if (!sentinelRef.current) {
sentinelRef.current = await navigator.wakeLock.request('screen');
sentinelRef.current.addEventListener('release', () => {
sentinelRef.current = null;
});
}
} catch (e) {
console.warn('Wake Lock unavailable:', e);
}
};
request();
// Re-request on visibility change
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && isPlaying && enabled) {
request();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
sentinelRef.current?.release();
};
}, [enabled, isPlaying]);
}Settings UI:
tsx
// src/components/Settings/WakeLockToggle.tsx
export function WakeLockToggle() {
const [enabled, setEnabled] = useStoredState('wakeLock', false);
const { isPlaying } = usePlayer();
return (
<label className="setting-row">
<div className="setting-info">
<span className="setting-title">Keep Screen On</span>
<span className="setting-desc">Prevents screen sleep during playback</span>
</div>
<Toggle
checked={enabled}
onChange={setEnabled}
disabled={!isPlaying}
/>
</label>
);
}4. Autoplay Policy Handling
typescript
// src/hooks/useAutoplay.ts
export function useAutoplay() {
const [blocked, setBlocked] = useState(false);
const player = usePlayer();
const tryPlay = useCallback(async () => {
try {
await player.play();
setBlocked(false);
} catch (e: any) {
if (e.name === 'NotAllowedError') {
setBlocked(true);
}
}
}, [player]);
// Listen for user gesture to retry
useEffect(() => {
if (!blocked) return;
const onGesture = () => {
tryPlay();
document.removeEventListener('click', onGesture);
document.removeEventListener('keydown', onGesture);
document.removeEventListener('touchstart', onGesture);
};
document.addEventListener('click', onGesture, { once: true });
document.addEventListener('keydown', onGesture, { once: true });
document.addEventListener('touchstart', onGesture, { once: true });
return () => {
document.removeEventListener('click', onGesture);
document.removeEventListener('keydown', onGesture);
document.removeEventListener('touchstart', onGesture);
};
}, [blocked, tryPlay]);
return { blocked, tryPlay };
}UI:
tsx
// src/components/Player/AutoplayUnblock.tsx
export function AutoplayUnblock({ onUnblock }) {
return (
<div className="autoplay-unblock" role="alert">
<VolumeXIcon className="icon" />
<span>Sound blocked — tap to enable</span>
<button className="btn-primary" onClick={onUnblock}>
Enable Sound
</button>
</div>
);
}Offline Support
Strategy: Cache-First for Assets, Network-First for Data
Service Worker Caching (Workbox)
typescript
// vite.config.ts (PWA config)
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,wasm}'],
// Precaching: all static assets
// (auto-generated from build output)
runtimeCaching: [
// Station API: Network first, 24h cache
{
urlPattern: /^https:\/\/api\.ifm\.sh\/stations/,
handler: 'NetworkFirst',
options: {
cacheName: 'stations-api',
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
networkTimeoutSeconds: 5,
plugins: [
{
// Add offline indicator to response
async fetchDidSucceed({ response }) {
const cloned = response.clone();
const data = await cloned.json();
return new Response(JSON.stringify({ ...data, cached: false }), {
headers: response.headers,
});
},
async fetchDidFail({ request }) {
// Return cached version with flag
const cache = await caches.open('stations-api');
const cached = await cache.match(request);
if (cached) {
const data = await cached.json();
return new Response(JSON.stringify({ ...data, cached: true }), {
headers: cached.headers,
});
}
return new Response(JSON.stringify({ stations: [], cached: true, offline: true }), {
headers: { 'Content-Type': 'application/json' },
});
},
},
],
},
},
// Bootstrap peers: Cache first, 7d
{
urlPattern: /^https:\/\/bootstrap\.ifm\.sh/,
handler: 'CacheFirst',
options: {
cacheName: 'bootstrap-peers',
expiration: { maxEntries: 10, maxAgeSeconds: 604800 },
},
},
// Images: Cache first, 30d
{
urlPattern: /\.(?:png|jpg|jpeg|svg|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: { maxEntries: 200, maxAgeSeconds: 2592000 },
},
},
],
}IndexedDB for User Data
typescript
// src/lib/indexeddb.ts
import { openDB, DBSchema } from 'idb';
interface IfmDB extends DBSchema {
stations: {
key: string; // frequency
value: CachedStation;
indexes: { 'by-category': string; 'by-last-played': number };
};
favorites: {
key: string; // frequency
value: Favorite;
};
protectedKeys: {
key: string; // frequency
value: EncryptedKey;
};
history: {
key: number; // auto-increment
value: HistoryEntry;
indexes: { 'by-date': number };
};
pendingActions: {
key: number; // auto-increment
value: PendingAction;
indexes: { 'by-type': string };
};
}
interface CachedStation {
frequency: string;
name?: string;
category: string;
listenerCount: number;
accessType: 'public' | 'protected' | 'hidden';
signalStrength: number;
artworkUrl?: string;
cachedAt: number;
lastPlayed?: number;
}
interface EncryptedKey {
frequency: string;
encryptedKey: string; // Base64(iv + ciphertext)
algorithm: 'AES-GCM';
added: number;
}
interface PendingAction {
type: 'chat' | 'favorite' | 'reaction';
payload: any;
timestamp: number;
retries: number;
}Background Sync for Offline Actions
typescript
// public/sw.js (Background Sync)
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions', {
maxRetentionTime: 24 * 60, // 24 hours
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
} catch (e) {
// Re-queue if still failing
await queue.unshiftRequest(entry);
throw e; // Triggers retry
}
}
},
});
// Register sync for mutating requests
registerRoute(
({ request, url }) =>
url.pathname.startsWith('/api/') &&
['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method),
new NetworkOnly({
plugins: [bgSyncPlugin],
})
);
// Periodic sync for station list refresh
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'refresh-stations') {
event.waitUntil(refreshStationList());
}
});
// Register periodic sync (requires user engagement)
async function registerPeriodicSync() {
const reg = await navigator.serviceWorker.ready;
try {
await reg.periodicSync.register('refresh-stations', {
minInterval: 12 * 60 * 60 * 1000, // 12 hours
});
} catch (e) {
console.log('Periodic sync not available:', e);
}
}Offline UI Indicators
tsx
// src/components/OfflineIndicator.tsx
export function OfflineIndicator() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [cachedData, setCachedData] = useState(false);
useEffect(() => {
const onOnline = () => { setIsOnline(true); setCachedData(false); };
const onOffline = () => setIsOnline(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
if (isOnline && !cachedData) return null;
return (
<div className={`offline-banner ${isOnline ? 'reconnecting' : 'offline'}`}>
<WifiOffIcon />
<span>
{isOnline
? 'Reconnecting... using cached data'
: 'Offline — showing cached stations'}
</span>
{cachedData && <CachedBadge />}
</div>
);
}impeccable.style UI Implementation
Design System Integration
Source: https://impeccable.style/#downloads
impeccable.style provides:
- Color tokens — Semantic, accessible palette
- Spacing scale — 4px base, consistent rhythm
- Typography — Inter + JetBrains Mono, fluid scaling
- Border radius — Consistent rounding
- Shadows — Elevation system
- Motion — Respects
prefers-reduced-motion
CSS Custom Properties Mapping
css
/* src/styles/variables.css */
/* Map impeccable.style tokens to IFM semantic tokens */
@import 'impeccable.style/tokens.css';
:root {
/* Color: Map to IFM semantic names */
--ifm-bg: var(--color-neutral-950); /* #0A0A0A */
--ifm-bg-elevated: var(--color-neutral-900); /* #141414 */
--ifm-fg: var(--color-neutral-50); /* #FAFAFA */
--ifm-fg-muted: var(--color-neutral-400); /* #A3A3A3 */
--ifm-accent: var(--color-orange-500); /* #FF6B00 */
--ifm-accent-hover: var(--color-orange-400);
--ifm-live: var(--color-red-500); /* #FF3B30 */
--ifm-connected: var(--color-green-500); /* #34C759 */
/* Spacing: Use impeccable.style scale directly */
--ifm-space-1: var(--space-1); /* 4px */
--ifm-space-2: var(--space-2); /* 8px */
--ifm-space-3: var(--space-3); /* 12px */
--ifm-space-4: var(--space-4); /* 16px */
--ifm-space-5: var(--space-5); /* 20px */
--ifm-space-6: var(--space-6); /* 24px */
--ifm-space-8: var(--space-8); /* 32px */
--ifm-space-10: var(--space-10); /* 40px */
--ifm-space-12: var(--space-12); /* 48px */
/* Typography */
--ifm-font-sans: var(--font-sans); /* Inter */
--ifm-font-mono: var(--font-mono); /* JetBrains Mono */
--ifm-text-xs: var(--text-xs); /* 12px */
--ifm-text-sm: var(--text-sm); /* 14px */
--ifm-text-base: var(--text-base); /* 16px */
--ifm-text-lg: var(--text-lg); /* 18px */
--ifm-text-xl: var(--text-xl); /* 24px */
--ifm-text-2xl: var(--text-2xl); /* 32px */
--ifm-text-3xl: var(--text-3xl); /* 48px */
--ifm-text-4xl: var(--text-4xl); /* 72px */
/* Radius */
--ifm-radius-sm: var(--radius-sm); /* 6px */
--ifm-radius-md: var(--radius-md); /* 10px */
--ifm-radius-lg: var(--radius-lg); /* 16px */
--ifm-radius-full: var(--radius-full); /* 9999px */
/* Shadows */
--ifm-shadow-sm: var(--shadow-sm);
--ifm-shadow-md: var(--shadow-md);
--ifm-shadow-lg: var(--shadow-lg);
/* Transitions */
--ifm-transition-fast: var(--transition-fast); /* 150ms */
--ifm-transition-base: var(--transition-base); /* 250ms */
--ifm-transition-slow: var(--transition-slow); /* 350ms */
}Component Library (impeccable.style Primitives)
tsx
// src/components/ui/Button.tsx
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 font-medium transition-colors ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ' +
'disabled:pointer-events-none disabled:opacity-50 ' +
'active:scale-[0.98] touch-manipulation',
{
variants: {
variant: {
primary: 'bg-accent text-white hover:bg-accent-hover shadow-md',
secondary: 'bg-bg-elevated text-fg border border-card-border hover:bg-bg-hover',
ghost: 'bg-transparent text-fg hover:bg-bg-hover',
destructive: 'bg-red-500 text-white hover:bg-red-600',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
xl: 'h-16 px-8 text-xl', // Large touch target
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);tsx
// src/components/ui/Toggle.tsx
const toggleVariants = cva(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ' +
'disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
checked: {
true: 'bg-accent',
false: 'bg-card-border',
},
},
defaultVariants: { checked: false },
}
);
const thumbVariants = cva(
'block h-5 w-5 rounded-full bg-white shadow-md transition-transform ' +
'data-[state=checked]:translate-x-5',
{ variants: { checked: { true: '', false: '' } } }
);
export function Toggle({ checked, onChange, disabled, className, ...props }) {
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(toggleVariants({ checked }), className)}
onClick={() => !disabled && onChange(!checked)}
{...props}
>
<span className={cn(thumbVariants({ checked }))} data-state={checked ? 'checked' : 'unchecked'} />
</button>
);
}tsx
// src/components/ui/Card.tsx
const cardVariants = cva(
'bg-card border border-card-border rounded-lg transition-shadow ' +
'hover:shadow-md',
{ variants: { padded: { true: 'p-4', false: '' } } }
);
export const Card = React.forwardRef<HTMLDivElement, { padded?: boolean; className?: string }>(
({ padded = true, className, ...props }, ref) => (
<div ref={ref} className={cn(cardVariants({ padded }), className)} {...props} />
)
);Radio-Optimized Components
Frequency Knob (Large Touch Target)
tsx
// src/components/Player/FrequencyKnob.tsx
export function FrequencyKnob({ frequency, onChange, min = 87.5, max = 108.0, step = 0.1 }) {
const [localFreq, setLocalFreq] = useState(frequency);
const knobRef = useRef<HTMLDivElement>(null);
const startRef = useRef({ y: 0, freq: 0 });
const handleMove = useCallback((clientY: number) => {
const delta = (startRef.current.y - clientY) * 0.02; // Sensitivity
const newFreq = Math.round((startRef.current.freq + delta) * 10) / 10;
const clamped = Math.max(min, Math.min(max, newFreq));
setLocalFreq(clamped);
}, [min, max]);
const handleStart = (e: React.PointerEvent) => {
startRef.current = { y: e.clientY, freq: localFreq };
knobRef.current?.setPointerCapture(e.pointerId);
};
const handleEnd = () => {
onChange(localFreq);
knobRef.current?.releasePointerCapture?.(0);
};
return (
<div
ref={knobRef}
className="frequency-knob"
onPointerDown={handleStart}
onPointerMove={(e) => e.buttons === 1 && handleMove(e.clientY)}
onPointerUp={handleEnd}
onPointerLeave={handleEnd}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={localFreq}
aria-label="Frequency"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); setLocalFreq(f => Math.min(max, f + step)); }
if (e.key === 'ArrowDown') { e.preventDefault(); setLocalFreq(f => Math.max(min, f - step)); }
if (e.key === 'Enter') onChange(localFreq);
}}
>
<div className="knob-face">
<span className="knob-frequency">{localFreq.toFixed(1)}</span>
<span className="knob-label">FM</span>
</div>
<div className="knob-indicator" />
</div>
);
}css
.frequency-knob {
width: 160px;
height: 160px;
touch-action: none;
user-select: none;
}
.knob-face {
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, var(--ifm-bg-elevated), var(--ifm-bg));
border: 2px solid var(--ifm-card-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow:
inset 0 2px 4px rgba(255,255,255,0.05),
inset 0 -2px 4px rgba(0,0,0,0.3),
0 8px 24px rgba(0,0,0,0.4);
}
.knob-frequency {
font-family: var(--ifm-font-mono);
font-size: var(--ifm-text-3xl);
font-weight: 700;
color: var(--ifm-fg);
}
.knob-label {
font-size: var(--ifm-text-xs);
color: var(--ifm-fg-subtle);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.knob-indicator {
position: absolute;
top: -4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 12px;
background: var(--ifm-accent);
border-radius: 2px;
}Station Card (Touch-Optimized)
tsx
// src/components/Discover/StationCard.tsx
export function StationCard({ station, onPress, onFavorite }) {
const isFavorite = useFavorite(station.frequency);
const [pressed, setPressed] = useState(false);
return (
<button
className={cn('station-card', pressed && 'pressed')}
onClick={onPress}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
onTouchStart={() => setPressed(true)}
onTouchEnd={() => setPressed(false)}
>
<div className="station-main">
<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-badge">{station.category}</span>
<span className="listeners">{station.listenerCount} listeners</span>
<SignalBars strength={station.signalStrength} />
</div>
</div>
<div className="station-actions">
{station.accessType === 'protected' && <LockIcon className="icon-lock" />}
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); onFavorite(); }}
className={isFavorite ? 'favorited' : ''}
>
<StarIcon className={cn('icon', isFavorite && 'filled')} />
</Button>
<Button variant="primary" size="lg" className="play-btn">
<PlayIcon />
</Button>
</div>
</button>
);
}css
.station-card {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--ifm-space-4);
align-items: center;
padding: var(--ifm-space-4);
background: var(--ifm-card);
border: 1px solid var(--ifm-card-border);
border-radius: var(--ifm-radius-lg);
transition: all var(--ifm-transition-fast);
touch-action: manipulation;
}
.station-card:active,
.station-card.pressed {
background: var(--ifm-bg-hover);
transform: scale(0.99);
}
.station-frequency {
display: flex;
flex-direction: column;
gap: var(--ifm-space-1);
}
.freq-number {
font-family: var(--ifm-font-mono);
font-size: var(--ifm-text-xl);
font-weight: 600;
color: var(--ifm-fg);
}
.freq-name {
font-size: var(--ifm-text-sm);
color: var(--ifm-fg-muted);
}
.station-meta {
display: flex;
flex-wrap: wrap;
gap: var(--ifm-space-3);
font-size: var(--ifm-text-sm);
color: var(--ifm-fg-subtle);
}
.category-badge {
padding: var(--ifm-space-1) var(--ifm-space-2);
background: var(--ifm-accent-muted);
color: var(--ifm-accent);
border-radius: var(--ifm-radius-full);
font-size: var(--ifm-text-xs);
font-weight: 500;
}
.station-actions {
display: flex;
align-items: center;
gap: var(--ifm-space-2);
}
.play-btn {
width: 48px;
height: 48px;
border-radius: var(--ifm-radius-full);
}Testing Checklist
Background Audio
- [ ] iOS Safari: Lock screen → audio continues 30+ min
- [ ] iOS Safari: Lock screen → media controls work (play/pause, next/prev)
- [ ] Android Chrome: Lock screen → audio continues
- [ ] Android Chrome: Lock screen → media controls work
- [ ] App switch → audio continues
- [ ] Tab background → audio continues (no throttling)
- [ ] Wake Lock: Screen stays on when enabled
- [ ] Autoplay blocked → "Tap to enable" → works on tap
Offline
- [ ] Disconnect WiFi → station list visible (cached)
- [ ] Tap cached station → plays cached init segment
- [ ] Reconnect → auto-refresh station list
- [ ] Offline chat send → queued → sent on reconnect
- [ ] Favorite toggle offline → persists → syncs on reconnect
impeccable.style UI
- [ ] Color contrast ≥ 4.5:1 (WCAG AA)
- [ ] Touch targets ≥ 48×48dp
- [ ] Focus visible on all interactive elements
- [ ] Reduced motion respected
- [ ] High contrast mode respected
- [ ] Dark/light theme (system preference)
- [ ] iOS Safe Area insets handled
- [ ] Android navigation bar handled