Skip to content

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

Released under the MIT License.