audiocnSolid

Music Player

A playlist player with a waveform seek bar, shuffle, repeat and ducking under the microphone.

Preparing tracks…

Installation

$ pnpm dlx shadcn@latest add @audiocn-solid/music-player

What it does

  • A player with a waveform seek bar that shows what has played, hover time, and elapsed and remaining time.
  • A track list: select a track to play it; the list advances on its own.
  • Shuffle, repeat all and repeat one.
  • With a duckingSource, the music drops while the source is active, with an on/off switch and an amount. It comes back on its own when the source goes quiet or stops.

Ducking under the microphone

Props

Prop

Type

Files

components/blocks/music-player/music-player.tsx
import { For, Show, createSignal, createUniqueId } from "solid-js";

import {
  MusicNotesIcon,
  PauseFillIcon,
  PlayFillIcon,
  RepeatIcon,
  RepeatOnceIcon,
  ShuffleIcon,
  SkipBackIcon,
  SkipForwardIcon,
  SpeakerHighIcon,
  SpeakerXIcon,
} from "@/components/icons/phosphor";
import {
  AudioPlayer,
  AudioPlayerArtwork,
  AudioPlayerControls,
  AudioPlayerDescription,
  AudioPlayerNext,
  AudioPlayerPlay,
  AudioPlayerPrevious,
  AudioPlayerTime,
  AudioPlayerTitle,
  AudioPlayerVolume,
} from "@/components/ui/audio-player";
import { Card, CardContent } from "@/components/ui/card";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Label } from "@/components/ui/label";
import {
  ParameterSlider,
  ParameterSliderControl,
  ParameterSliderHeader,
  ParameterSliderLabel,
  ParameterSliderValue,
} from "@/components/ui/parameter-slider";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import {
  TrackList,
  TrackListItem,
  TrackListItemContent,
  TrackListItemDescription,
  TrackListItemDuration,
  TrackListItemIndex,
  TrackListItemTitle,
} from "@/components/ui/track-list";
import {
  VolumeControlMute,
  VolumeControlSlider,
} from "@/components/ui/volume-control";
import {
  Waveform,
  WaveformCanvas,
  WaveformCursor,
  WaveformHover,
} from "@/components/ui/waveform";
import {
  disconnectFrom,
  getMediaElementSource,
} from "@/hooks/use-audio-analyser";
import { useAudioContext } from "@/hooks/use-audio-context";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useFrameSource } from "@/hooks/use-frame-source";
import { useWaveformData } from "@/hooks/use-waveform-data";
import { dbToGain } from "@/lib/audio/decibels";
import { formatTime } from "@/lib/audio/time";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";

export interface MusicTrack {
  id: string;
  title: string;
  artist?: string;
  src: string;
  artwork?: string;
  duration?: number;
}

export interface MusicPlayerProps {
  tracks?: MusicTrack[];
  defaultTracks?: MusicTrack[];
  onTrackChange?: (track: MusicTrack) => void;
  duckingSource?: FrameSource<MeterFrame> | null;
  output?: AudioNode | null;
  class?: string;
  className?: string;
}

type Repeat = "off" | "all" | "one";

const NO_TRACKS: MusicTrack[] = [];

const DUCK_THRESHOLD_DB = -35;

const DUCK_ATTACK = 0.02;

const DUCK_RELEASE = 0.15;

const DUCK_HOLD = 0.2;

const nextRepeat = { all: "one", off: "all", one: "off" } satisfies Record<
  Repeat,
  Repeat
>;

export const MusicPlayer = (props: MusicPlayerProps) => {
  const tracks = () => props.tracks ?? props.defaultTracks ?? NO_TRACKS;
  const duckingId = createUniqueId();
  const [index, setIndex] = createSignal(0);
  const position = () => Math.min(index(), Math.max(0, tracks().length - 1));
  const [shuffle, setShuffle] = createSignal(false);
  const [repeat, setRepeat] = createSignal<Repeat>("all");
  const [ducking, setDucking] = createSignal(true);
  const [autoAdvance, setAutoAdvance] = createSignal(false);
  const [duckAmountDb, setDuckAmountDb] = createSignal(-12);
  const track = () => tracks()[position()];

  const go = (direction: number) => {
    const count = tracks().length;

    if (!count) return;
    let next = (position() + direction + count) % count;

    if (shuffle() && count > 1)
      next = (position() + 1 + Math.floor(Math.random() * (count - 1))) % count;
    setIndex(next);
    setAutoAdvance(true);
    const nextTrack = tracks()[next];

    if (nextTrack) props.onTrackChange?.(nextTrack);
  };

  const player = useAudioPlayer(() => ({
    autoPlay: autoAdvance(),
    loop: repeat() === "one",
    src: track()?.src,
    onEnded: () => {
      if (repeat() === "all" || position() < tracks().length - 1) go(1);
    },
  }));

  const waveform = useWaveformData(() => track()?.src ?? null, {
    samples: 400,
  });

  const { context } = useAudioContext();
  const duckGain = context?.createGain() ?? null;

  const routed = () =>
    props.output !== undefined || props.duckingSource !== undefined;

  createCompatEffect(
    () => ({ routed: routed(), output: props.output }),
    (settings) => {
      if (!(context && duckGain && player.element && settings.routed)) return;
      const source = getMediaElementSource(context, player.element);
      disconnectFrom(source, context.destination);
      source.connect(duckGain);

      const target =
        settings.output === undefined ? context.destination : settings.output;

      if (target) duckGain.connect(target);

      return () => {
        disconnectFrom(source, duckGain);

        if (target) disconnectFrom(duckGain, target);
        source.connect(context.destination);
      };
    }
  );
  useFrameSource(
    () => props.duckingSource,
    (frame) => {
      if (!(duckGain && context && ducking())) return;
      let loudest = Number.NEGATIVE_INFINITY;

      for (const level of frame.channels)
        loudest = Math.max(loudest, level.peakDb);

      if (loudest < DUCK_THRESHOLD_DB) return;
      const now = context.currentTime;
      duckGain.gain.cancelScheduledValues(now);
      duckGain.gain.setTargetAtTime(dbToGain(duckAmountDb()), now, DUCK_ATTACK);
      duckGain.gain.setTargetAtTime(1, now + DUCK_HOLD, DUCK_RELEASE);
    },
    {
      get enabled() {
        return routed() && Boolean(duckGain);
      },
    }
  );

  return (
    <Show
      when={tracks().length}
      fallback={
        <Empty class={props.class ?? props.className}>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <MusicNotesIcon />
            </EmptyMedia>
            <EmptyTitle>No music</EmptyTitle>
            <EmptyDescription>Add tracks to start playing.</EmptyDescription>
          </EmptyHeader>
        </Empty>
      }
    >
      <Card class={props.class ?? props.className}>
        <CardContent>
          <div class="flex flex-col gap-4">
            <AudioPlayer
              class="flex-col items-stretch gap-3"
              onNext={() => go(1)}
              onPrevious={() => go(-1)}
              player={player}
            >
              <div class="flex items-center gap-3">
                <Show
                  when={track()?.artwork}
                  fallback={
                    <span class="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-lg">
                      <MusicNotesIcon class="size-5" />
                    </span>
                  }
                >
                  {(src) => <AudioPlayerArtwork src={src()} />}
                </Show>
                <div class="flex min-w-0 flex-1 flex-col">
                  <AudioPlayerTitle>{track()?.title}</AudioPlayerTitle>
                  <AudioPlayerDescription>
                    {track()?.artist}
                  </AudioPlayerDescription>
                </div>
              </div>
              <Waveform
                class="h-14"
                currentTime={player.currentTime}
                duration={waveform.duration || player.duration}
                loading={waveform.status === "loading"}
                onSeekCommitted={(value) => player.seek(value)}
                peaks={waveform.peaks}
                time={player.time}
              >
                <WaveformCanvas />
                <WaveformCursor />
                <WaveformHover />
              </Waveform>
              <div class="flex items-center justify-between">
                <AudioPlayerTime />
                <AudioPlayerTime type="remaining" />
              </div>
              <div class="flex items-center justify-between gap-2">
                <Toggle
                  aria-label="Shuffle"
                  onPressedChange={setShuffle}
                  pressed={shuffle()}
                  size="sm"
                >
                  <ShuffleIcon />
                </Toggle>
                <AudioPlayerControls>
                  <AudioPlayerPrevious>
                    <SkipBackIcon />
                  </AudioPlayerPrevious>
                  <AudioPlayerPlay>
                    {(state) =>
                      state.playing ? <PauseFillIcon /> : <PlayFillIcon />
                    }
                  </AudioPlayerPlay>
                  <AudioPlayerNext>
                    <SkipForwardIcon />
                  </AudioPlayerNext>
                </AudioPlayerControls>
                <Toggle
                  aria-label={`Repeat: ${repeat()}`}
                  onPressedChange={() => setRepeat(nextRepeat[repeat()])}
                  pressed={repeat() !== "off"}
                  size="sm"
                >
                  <Show when={repeat() === "one"} fallback={<RepeatIcon />}>
                    <RepeatOnceIcon />
                  </Show>
                </Toggle>
              </div>
              <AudioPlayerVolume>
                <VolumeControlMute>
                  {player.muted ? <SpeakerXIcon /> : <SpeakerHighIcon />}
                </VolumeControlMute>
                <VolumeControlSlider />
              </AudioPlayerVolume>
            </AudioPlayer>
            <TrackList variant="outline">
              <For each={tracks()}>
                {(item, row) => (
                  <TrackListItem
                    active={row() === position()}
                    playing={row() === position() && player.playing}
                    onSelect={() => {
                      const same = row() === position();
                      setIndex(row());
                      props.onTrackChange?.(item);

                      if (same) void player.toggle();
                      else setAutoAdvance(true);
                    }}
                  >
                    <TrackListItemIndex>{row() + 1}</TrackListItemIndex>
                    <TrackListItemContent>
                      <TrackListItemTitle>{item.title}</TrackListItemTitle>
                      <Show when={item.artist}>
                        <TrackListItemDescription>
                          {item.artist}
                        </TrackListItemDescription>
                      </Show>
                    </TrackListItemContent>
                    <TrackListItemDuration>
                      {item.duration ? formatTime(item.duration) : null}
                    </TrackListItemDuration>
                  </TrackListItem>
                )}
              </For>
            </TrackList>
            <Show when={props.duckingSource !== undefined}>
              <div class="flex flex-col gap-3 rounded-xl border p-3">
                <div class="flex items-center justify-between">
                  <Label for={duckingId}>Lower music while you talk</Label>
                  <Switch
                    checked={ducking()}
                    id={duckingId}
                    onCheckedChange={setDucking}
                    size="sm"
                  />
                </div>
                <ParameterSlider
                  disabled={!ducking()}
                  max={0}
                  min={-30}
                  onValueChange={setDuckAmountDb}
                  unit="dB"
                  value={duckAmountDb()}
                >
                  <ParameterSliderHeader>
                    <ParameterSliderLabel>Amount</ParameterSliderLabel>
                    <ParameterSliderValue />
                  </ParameterSliderHeader>
                  <ParameterSliderControl />
                </ParameterSlider>
              </div>
            </Show>
          </div>
        </CardContent>
      </Card>
    </Show>
  );
};