tunein-cli 0.6.0

Browse and listen to thousands of radio stations across the globe right from your terminal 🌎 📻 🎵✨
import { Link } from '@tanstack/react-router'
import {
  IconChevronRight,
  IconFolder,
  IconHeart,
  IconHeartFilled,
  IconPlayerPlayFilled,
  IconPlayerStopFilled,
  IconRadio,
} from '@tabler/icons-react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtom, useAtomValue } from 'jotai'
import { addFavorite, fetchFavorites, isFolder, removeFavorite, type Station } from '../lib/api'
import { currentStationAtom, isPlayingAtom, providerAtom, type ProviderId } from '../state/atoms'

function EqBars() {
  return (
    <span className="flex h-3.5 items-end gap-[3px]" aria-label="Playing">
      <span className="w-[3px] origin-bottom animate-eq-1 rounded-full bg-violet-400" style={{ height: '100%' }} />
      <span className="w-[3px] origin-bottom animate-eq-2 rounded-full bg-fuchsia-400" style={{ height: '100%' }} />
      <span className="w-[3px] origin-bottom animate-eq-3 rounded-full bg-violet-400" style={{ height: '100%' }} />
    </span>
  )
}

function FavoriteButton({ station, provider }: { station: Station; provider: ProviderId }) {
  const queryClient = useQueryClient()
  const favorites = useQuery({ queryKey: ['favorites'], queryFn: fetchFavorites })
  const isFavorite =
    favorites.data?.some((f) => f.id === station.id && f.provider === provider) ?? false

  const toggle = useMutation({
    mutationFn: () =>
      isFavorite
        ? removeFavorite(station.id, provider)
        : addFavorite({ id: station.id, name: station.name, provider }),
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }),
  })

  return (
    <button
      type="button"
      onClick={() => toggle.mutate()}
      disabled={toggle.isPending || !station.id}
      className="shrink-0 p-1 transition-transform hover:scale-110 disabled:opacity-50"
      aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
    >
      {isFavorite ? (
        <IconHeartFilled size={18} className="text-fuchsia-400" />
      ) : (
        <IconHeart size={18} className="text-zinc-600 transition-colors hover:text-fuchsia-300" />
      )}
    </button>
  )
}

export function StationCard({
  station,
  provider: providerOverride,
}: {
  station: Station
  provider?: ProviderId
}) {
  const activeProvider = useAtomValue(providerAtom)
  const provider = providerOverride ?? activeProvider
  const [current, setCurrent] = useAtom(currentStationAtom)
  const [isPlaying, setIsPlaying] = useAtom(isPlayingAtom)

  if (isFolder(station, provider)) {
    return (
      <Link
        to="/browse/$category"
        params={{ category: station.id }}
        search={{ name: station.name }}
        className="group flex items-center gap-4 rounded-2xl border border-white/5 bg-white/[0.03] p-4 transition-all hover:border-violet-500/30 hover:bg-white/[0.06]"
      >
        <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-zinc-800/80 text-zinc-400 transition-colors group-hover:text-violet-300">
          <IconFolder size={22} />
        </span>
        <span className="min-w-0 flex-1">
          <span className="block truncate font-medium text-zinc-100">{station.name}</span>
          <span className="block truncate text-sm text-zinc-500">Browse category</span>
        </span>
        <IconChevronRight
          size={18}
          className="shrink-0 text-zinc-600 transition-transform group-hover:translate-x-0.5 group-hover:text-violet-300"
        />
      </Link>
    )
  }

  const isCurrent = current?.id === station.id
  const isActive = isCurrent && isPlaying

  const toggle = () => {
    if (!station.id) return
    if (isCurrent) {
      setIsPlaying(!isPlaying)
      return
    }
    setCurrent({
      id: station.id,
      name: station.name,
      provider,
      subtitle: station.playing,
      streamUrl: station.streamUrl || undefined,
    })
    setIsPlaying(true)
  }

  return (
    <div
      className={`group flex items-center gap-2 rounded-2xl border p-4 transition-all ${
        isCurrent
          ? 'border-violet-500/40 bg-violet-500/10'
          : 'border-white/5 bg-white/[0.03] hover:border-violet-500/30 hover:bg-white/[0.06]'
      }`}
    >
      <button
        type="button"
        onClick={toggle}
        disabled={!station.id}
        className="flex min-w-0 flex-1 items-center gap-4 text-left disabled:cursor-not-allowed disabled:opacity-50"
      >
        <span className="relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-violet-500/25 to-fuchsia-600/25 text-violet-300">
          {isActive ? (
            <EqBars />
          ) : (
            <>
              <IconRadio size={22} className="transition-opacity group-hover:opacity-0" />
              <IconPlayerPlayFilled
                size={20}
                className="absolute opacity-0 transition-opacity group-hover:opacity-100"
              />
            </>
          )}
        </span>

        <span className="min-w-0 flex-1">
          <span className={`block truncate font-medium ${isCurrent ? 'text-violet-200' : 'text-zinc-100'}`}>
            {station.name}
          </span>
          <span className="block truncate text-sm text-zinc-500">
            {station.playing || 'Live radio'}
          </span>
        </span>

        <span className="flex shrink-0 items-center gap-2">
          {station.codec && (
            <span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-zinc-400 uppercase">
              {station.codec}
            </span>
          )}
          {station.bitrate > 0 && (
            <span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-zinc-400">
              {station.bitrate}k
            </span>
          )}
          {isActive && <IconPlayerStopFilled size={16} className="text-violet-300" />}
        </span>
      </button>

      <FavoriteButton station={station} provider={provider} />
    </div>
  )
}

export function StationGrid({ stations, provider }: { stations: Station[]; provider?: ProviderId }) {
  return (
    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
      {stations.map((station, i) => (
        <StationCard key={`${station.id || station.name}-${i}`} station={station} provider={provider} />
      ))}
    </div>
  )
}

export function StationGridSkeleton({ count = 9 }: { count?: number }) {
  return (
    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
      {Array.from({ length: count }, (_, i) => (
        <div
          key={i}
          className="flex animate-pulse items-center gap-4 rounded-2xl border border-white/5 bg-white/[0.03] p-4"
        >
          <div className="size-12 shrink-0 rounded-xl bg-zinc-800/80" />
          <div className="flex-1 space-y-2">
            <div className="h-3.5 w-2/3 rounded bg-zinc-800/80" />
            <div className="h-3 w-1/3 rounded bg-zinc-800/60" />
          </div>
        </div>
      ))}
    </div>
  )
}