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.