GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI

音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールします。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

この9種はどれも実際の音声を1バイトも読まずに動きます。曲の再生位置・棒の高さ・盤の回転速度・歌詞の行番号は、すべてサイン波の合成やタイマーの経過値から計算だけで作られた数字です。ミュージック プレイヤー uiとして見た目を組み立てるとき、進行率や角速度のような値をどこで一度だけ計算し、どこで毎フレーム読み直すかが9つそれぞれで違うので、その境目に沿って並べています。

デモ

再生進行率の値で描かれるカスタムコントロールバー
新しいタブで開く

01 · 再生進行率の値で描かれるカスタムコントロールバー — 計測 累積面積 2.546% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PROGRESS_UPDATE_MSseekTofmt

上のパターン01はPROGRESS_UPDATE_MSという定数と、経過秒数をシークハンドルの位置へ変換するseekTo関数、それをmm:ss表示に整えるfmt関数だけで動いています。残り8つも同じ形で、それぞれ専用のコードブロックを添えて下に並びます。

9つのカスタムオーディオプレイヤーUIパターンの解剖 — どこで計算の一層が回るか

# パターン 累積面積 平均強度 動いたフレーム
01 進行率バーのカスタムコントロール 2.546% 154.8 13/23
02 合成波形イコライザーバー 14.217% 163.9 23/23
03 慣性ターンテーブル回転 7.679% 67.9 23/23
04 スクロール折りたたみミニバー 30.593% 35.9 23/23
05 ドラッグキュー並べ替え 29.946% 46.7 23/23
06 円形スペクトラムリング 8.062% 101.9 23/23
07 プリセットイコライザースライダー 9.767% 177.3 23/23
08 歌詞タイムラインスクロール 1.891% 82.4 14/23
09 プレイヤーヒーローシーン 39.284% 31.2 23/23

9つともrequestAnimationFrameで駆動していますが、値をいつ計算し直すかで3つの層に分かれます。

一度だけ計算して毎フレーム読み返す合成値

02・06・07はmakeLcgという固定シード乱数で棒や点ごとの位相・周波数をページ読み込み時に一度だけ決め、synth関数がその値と経過秒数からサイン波2本を重ねて0〜1の高さを毎フレーム計算し直します。09も同じRING_POINTS個の位相セットを一度だけ用意し、drawRingがそれを読み返してリングを描きます。

状態が変わった瞬間だけ計算し直す値

03は一時停止した瞬間からSPIN_DECAYで角速度の値を毎フレーム少しずつ減らし、04はウィンドウのresizeが起きた瞬間だけrecomputeでミニバーへの切り替えしきい値を測り直し、05はドラッグの指がスロットをまたいだ瞬間だけslotOfで並び順の配列を組み替えます。

経過時間・スクロール位置から毎フレーム計算し直す値

01は経過秒数の値からseekToでシークハンドルの位置と残り時間表示を計算し直し、08は疑似再生タイムラインの値からactiveIndexOfで今ハイライトすべき歌詞行を探してスクロール位置を計算し、09はその両方を1つの再生進行率の値にまとめて3つの計算レイヤーへ同時に流し込みます。

パターン別コード

01再生進行率の値で描かれるカスタムコントロールバー

再生開始からの経過秒数をseekToで受け取り、シークハンドルの位置とfmtで整形した残り時間表示を毎フレーム再計算して描くカスタムオーディオコントロールです。トラックのクリック位置と矢印キーの両方から同じseekToを呼びます。

再生進行率の値で描かれるカスタムコントロールバー
新しいタブで開く

01 · 再生進行率の値で描かれるカスタムコントロールバー — 計測 累積面積 2.546% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PROGRESS_UPDATE_MSseekTofmt
  function seekTo(elapsedS) {
    state.elapsedS = Math.min(TRACK_LENGTH_S, Math.max(0, elapsedS));
    if (state.playing) state.playStart = performance.now() - state.elapsedS * 1000;
    render(state.elapsedS);
  }

  function onTrackClick(e) {
    human();
    var r = track.getBoundingClientRect();
    var ratio = (e.clientX - r.left) / r.width;
    seekTo(ratio * TRACK_LENGTH_S);
  }

  function onSeekKeydown(e) {
    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault();

02合成波形の値で揺れるイコライザーバー

実際の音声解析を1バイトも使わず、makeLcgが決めた位相・周波数をsynthが経過秒数と合成して棒ごとの高さの値をあらかじめ計算しておき、再生中はその値を時間に応じて揺らしてイコライザーのように見せます。291編の実際のWeb Audio解析バーとは違い、この項目は装飾用の合成データのみを使います。

合成波形の値で揺れるイコライザーバー
新しいタブで開く

02 · 合成波形の値で揺れるイコライザーバー — 計測 累積面積 14.217% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

makeLcgsynthBAR_COUNT
  function synth(bar, tSec) {
    var s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
    var s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
    var v = (s1 + s2) / (1 + bar.w2); // -1..1 の範囲に正規化
    return 0.5 + v * 0.5; // 0..1
  }

03角速度の値で回るターンテーブルのアルバムアート

再生状態から角速度の値を計算してアルバムディスクとトーンアームを毎フレーム回転させ、一時停止するとSPIN_DECAYでその値を徐々に減らして慣性で滑らかに止まるように見せます。

角速度の値で回るターンテーブルのアルバムアート
新しいタブで開く

03 · 角速度の値で回るターンテーブルのアルバムアート — 計測 累積面積 7.679% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPIN_DECAYARM_ANGLE_STARTstep
  function step(effPlaying, dt) {
    if (effPlaying) {
      velocity += (TARGET_VELOCITY - velocity) * SPIN_UP_RATE;
      progressS = Math.min(PLAY_LENGTH_S, progressS + dt);
    } else {
      velocity *= SPIN_DECAY; // 毎フレーム角速度を減らし慣性で止まるように見せる
    }
    angle = (angle + velocity * dt) % 360;
    disc.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)';
    var t = progressS / PLAY_LENGTH_S;
    var armAngle = ARM_ANGLE_START + (ARM_ANGLE_END - ARM_ANGLE_START) * t;
    arm.style.transform = 'rotate(' + armAngle.toFixed(2) + 'deg)';
  }

04スクロール値で折りたたまれる下部固定ミニバー

ページのスクロール位置の値を基準にrecomputeがフルプレイヤーと下部固定ミニバーの切り替え地点を計算し、そこを過ぎるとアルバムアートとコントロールが小さなピル状に縮んで画面下に固定されます。

スクロール値で折りたたまれる下部固定ミニバー
新しいタブで開く

04 · スクロール値で折りたたまれる下部固定ミニバー — 計測 累積面積 30.593% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MORPH_RANGE_PXrecomputeprogrammatic
  function recompute() {
    var threshold = full.offsetHeight; // 実際のカード高さを基準に切り替え時点を計算する
    var y = scrollEl.scrollTop;
    var p = Math.min(1, Math.max(0, (y - threshold) / MORPH_RANGE_PX));
    mini.style.opacity = String(p);
    mini.style.transform = 'translateY(' + ((1 - p) * 16) + 'px) scale(' + (0.94 + p * 0.06) + ')';
    mini.style.pointerEvents = p > 0.5 ? 'auto' : 'none';
  }

  function onScroll() {
    if (programmatic) { programmatic = false; recompute(); return; }
    human(); recompute();

05順序の値を振り直すキュードラッグ並べ替え

キュー項目をポインターで掴んで動かした縦位置を他の項目の位置の値と比較してslotOfが順序を振り直し、onPointerMoveが置き場所を決めるとその間の項目が自分の位置の値の分だけ動いて場所を空けます。

順序の値を振り直すキュードラッグ並べ替え
新しいタブで開く

05 · 順序の値を振り直すキュードラッグ並べ替え — 計測 累積面積 29.946% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SETTLE_RATEslotOfonPointerMove
  function onPointerMove(e) {
    if (dragIndex < 0) return;
    dragY = startSlotY + (e.clientY - startClientY);
    var toSlot = Math.max(0, Math.min(rows.length - 1, Math.round(dragY / ROW_H)));
    var fromSlot = slotOf(dragIndex);
    if (toSlot !== fromSlot) {
      order.splice(fromSlot, 1);
      order.splice(toSlot, 0, dragIndex);
    }
  }
  function onPointerUp(e) {
    if (dragIndex < 0) return;
    rows[dragIndex].classList.remove('is-dragging');
    try { rows[dragIndex].releasePointerCapture(e.pointerId); } catch (err) {}
    dragIndex = -1;

06半径の値で広がる円形スペクトラムリング

アルバムアートを囲む複数の点それぞれにmakeLcgが決めた位相をsynthが合成振幅の値へ変換し、再生中はその半径が時間とともに揺れてアートを囲む円形リングが脈打つように大きくなったり小さくなったりします。291編のWeb Audio解析とは違い、ここでもサイン合成だけの装飾用データです。

半径の値で広がる円形スペクトラムリング
新しいタブで開く

06 · 半径の値で広がる円形スペクトラムリング — 計測 累積面積 8.062% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

makeLcgsynthRANGE_R
  function synth(pt, tSec) {
    var s1 = Math.sin(tSec * pt.f1 * Math.PI * 2 + pt.p1);
    var s2 = Math.sin(tSec * pt.f2 * Math.PI * 2 + pt.p2) * pt.w2;
    var v = (s1 + s2) / (1 + pt.w2); // -1..1
    return 0.5 + v * 0.5; // 0..1
  }

07スライダー値がバーの反応幅を決めるプリセットイコライザー

低音・中音・高音の3つのスライダー値をそれぞれBAND_RESPONSE_RANGEと掛けて対応するバーグループの最大振れ幅を決め、スライダーを動かすとそのグループのバーだけが新しく計算された幅で揺れ直します。

スライダー値がバーの反応幅を決めるプリセットイコライザー
新しいタブで開く

07 · スライダー値がバーの反応幅を決めるプリセットイコライザー — 計測 累積面積 9.767% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BAND_RESPONSE_RANGEsynthmakeLcg
  function render(now) {
    var tSec = (now - t0) / 1000;
    var effPlaying = isDemo ? true : playing;
    for (var b = 0; b < BAND_COUNT; b++) {
      var norm = Number(sliders[b].value) / 100; // 0..1
      var amp = norm * BAND_RESPONSE_RANGE;
      var bars = bands[b];
      for (var i = 0; i < bars.length; i++) {
        var bar = bars[i];
        var sway = effPlaying ? synth(bar, tSec) : 0;
        var h = bar.base + sway * amp * 0.5;
        h = Math.max(0.06, Math.min(1, h));
        // バーは底が基準点なので下端の原点から縦にだけ伸びる — 中心基準ではなく実際のアンカー地点
        bar.el.style.transform = 'scaleY(' + h + ')';
      }
    }
  }

08タイムライン値でハイライトが移動する歌詞スクロール

疑似再生タイムライン値を基準にactiveIndexOfが各歌詞行の開始時刻と比較して今ハイライトすべき行を探し、seekToLineでその行が常に画面中央に来るようリストのスクロール位置も一緒に計算して動かします。

タイムライン値でハイライトが移動する歌詞スクロール
新しいタブで開く

08 · タイムライン値でハイライトが移動する歌詞スクロール — 計測 累積面積 1.891% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

activeIndexOfseekToLineLINE_TIMES
  function activeIndexOf(progressS) {
    var idx = 0;
    for (var i = 0; i < LINE_TIMES.length; i++) { if (LINE_TIMES[i] <= progressS) idx = i; }
    return idx;
  }

  function seekToLine(i) {
    i = Math.max(0, Math.min(LINE_COUNT - 1, i));
    playedMs = (LINE_TIMES[i] / SPEED) * 1000;
  }

  function render() {
    var progressS = (playedMs / 1000) * SPEED % PLAYBACK_LENGTH_S;
    var activeIndex = activeIndexOf(progressS);
    var target = activeIndex * LINE_H - (VIEWPORT_H / 2 - LINE_H / 2);
    var maxOffset = Math.max(0, LINE_COUNT * LINE_H - VIEWPORT_H);
    target = Math.max(0, Math.min(maxOffset, target));
    if (currentOffset == null) currentOffset = target;
    currentOffset += (target - currentOffset) * 0.22; // タイムラインが移った位置へ慣性を持って近づく
    list.style.transform = 'translateY(' + (-currentOffset).toFixed(2) + 'px)';

09ぼかし背景とリングが一画面で噛み合うプレイヤーヒーロー

アルバムアートから抽出した色の値をぼかして拡大した背景の上に、bandValueが計算する帯域値とdrawRingが読み返すRING_POINTS個の位相を重ね、1つの再生進行率の値が3レイヤーを同時に動かすヒーローシーンです。

ぼかし背景とリングが一画面で噛み合うプレイヤーヒーロー
新しいタブで開く

09 · ぼかし背景とリングが一画面で噛み合うプレイヤーヒーロー — 計測 累積面積 39.284% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

bandValuedrawRingRING_POINTS
  function drawRing(tSec, treb) {
    var w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
    var innerR = 34, rangeR = 16;
    ctx.clearRect(0, 0, w, h);
    ctx.lineCap = 'round';
    ctx.strokeStyle = ringColor;
    for (var i = 0; i < RING_POINTS; i++) {
      var pt = ringPts[i];
      var s = Math.sin(tSec * pt.f + pt.p) * 0.5 + 0.5;
      var v = 0.15 + s * pt.w * (0.4 + treb * 0.9);
      var angle = (i / RING_POINTS) * Math.PI * 2 - Math.PI / 2;
      var cos = Math.cos(angle), sin = Math.sin(angle);
      var r2 = innerR + rangeR * Math.max(0.05, v);
      ctx.globalAlpha = 0.3 + v * 0.6;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(cx + cos * innerR, cy + sin * innerR);
      ctx.lineTo(cx + cos * r2, cy + sin * r2);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;

値が画面を変える場所 — 進行率・角速度・タイムライン

01 — PROGRESS_UPDATE_MS

この定数は、経過秒数から再計算した値をシークハンドルと残り時間表示へ反映するまでの間隔をミリ秒で決めるものです。値そのものの計算方法ではなく、その計算をどれだけ頻繁に画面へ反映するかだけを変えます。

03 — SPIN_DECAY

これは一時停止した瞬間から角速度の値を毎フレーム何倍に減らすかの係数です。回転の向きではなく、ディスクが完全に止まるまでにかかる時間の長さだけを変えます。

07 — BAND_RESPONSE_RANGE

これはスライダー値に掛けて振れ幅の上限を決める倍率です。バーの本数ではなく、スライダーを動かしたときそのグループが揺れる高さの差だけを変えます。

バリエーション 変わる値 見た目の変化
PROGRESS_UPDATE_MS 200ms → 80ms 更新間隔値が200msから80msへ下がる シークハンドルの位置がより頻繁に再計算され、動きが滑らかになる方向に近づく
SPIN_DECAY 0.965 → 0.985 減衰値が0.965から0.985へ上がる 一時停止後にディスクが止まるまでの時間が長くなる方向に近づく
BAND_RESPONSE_RANGE 1.6 → 2.4 反応幅値が1.6から2.4へ上がる スライダーを動かしたときそのバーグループが揺れる高さの差がより大きくなる方向に近づく

どこで壊れるか — アクセシビリティ・reduced-motion・低スペック

9つとも同じ土台を共有しています。クリック可能なコントロールにはrole="button"または実際のボタン要素・aria-labelが付き、clickとEnter・Spaceのkeydownの両方に応答します。04は追加でresizeとscroll、05はpointerdown・pointermove・pointerup、07はinputにも応答します。window.matchMedia('(prefers-reduced-motion: reduce)')は読み込み時に一度JavaScript側でも読み取られます。

実音声は0です。 <audio>要素もWeb AudioのAudioContext・AnalyserNodeもfetchも、9つのどこにも存在しません。再生進行率・波形の高さ・角速度・歌詞のタイムラインはすべてサイン波の合成か固定シードの乱数(makeLcg)、あるいはタイマーの経過値から計算した合成データです。ミュージック プレイヤー uiの見た目を先に固めてから実際の音声APIへ差し替える、という順番を想定した構成です。

reduced-motionが有効なとき、9つとも自動でループするアニメーションフレームの呼び出しをスキップし、静的なスナップショットへ直接ジャンプしたうえで、クリック・Enterのハンドラーだけをアニメーションさせずに値を切り替えるものへ差し替えます。この設定の下でいつも通りのイージングを裏で動かし続けるパターンは1つもありません。

9つのリスナー配線とreduced-motionの分岐を項目ごとに追うより、パスワード239r99kfで開くzip内の9個のindex.htmlを並べて開くほうが、どこが共通でどこが違うかを一度に把握できます。

3つのバリエーションとアクセシビリティ

上の表がすでに、確認済みの3つのバリエーション — PROGRESS_UPDATE_MS・SPIN_DECAY・BAND_RESPONSE_RANGE — をすべて含んでいます。どのバリエーションもその1つの定数だけを書き換えたもので、リスナー・aria-*属性・reduced-motionの分岐は元のパターンとまったく同じままです。

音楽 プレイヤー デザイン の実装例はどこで見られる?

音楽 プレイヤー 風 デザインの実装例として多く見つかるのは、CSSのグラデーションだけで塗った進行バーか、静止画のアルバムジャケットを貼っただけのカードです。上の9つのパターンはそれとは違う場所に立っていて、経過秒数・角速度・スクロール位置のいずれかを素のJavaScriptで一度計算し、毎フレーム読み戻してコントロールを動かしています — ライブラリもCDNのスクリプトも使わず、上のzipとブラウザだけで完結します。

FAQ

パターン01とパターン08の動いたフレームが13/23・14/23と低いのはなぜですか?

01はPROGRESS_UPDATE_MSが200msごとにしかシークハンドルの位置を再計算しないため、23個のフレーム差分のうち約半分は前フレームと同じ値のままです。08はcurrentOffsetが目標位置へ0.22の比率で近づいたあと歌詞行の切り替わり(LINE_TIMESの間隔)まで値が収束して動かないので、同じように差分が少ない区間ができます。

パターン02とパターン06の棒やリングは、実際の曲のマイク入力や解析データを読んでいますか?

読んでいません。どちらもmakeLcgで固定した位相・周波数をsynth関数がサイン波2本の合成で0〜1の値に変換しているだけの、装飾用の合成データです。291編のオーディオビジュアライザーが使う実際のWeb Audio解析(AudioContext・AnalyserNode)とは値の出どころが違います。

音楽 プレイヤー uiとしてこのデモをそのまま実際のサイトに組み込めますか?

click・keydownのリスナー、aria-labelの付け方、そして各計算関数はそのまま移せます。ただしvolumeやtrackの秒数はデモ用の固定値なので、実際の曲データや再生イベントに差し替える配線は自分で足す必要があります。

Sources: MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — Pointer events

関連記事: オーディオビジュアライザー 作り方 9選 — 棒からマイクまで · ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種 · バッファ 表示 アニメーション 9種 — スピナーから画質バッジまで — 1つ目は棒の高さを実際のWeb Audio解析から読む点がこの編の合成データと対照的で、2つ目はパターン05のドラッグ並べ替えが使うのと同じポインター位置比較の計算をリスト全体に広げ、3つ目はこの編と同じ「値だけで動く」構成を待機表示という別の場面に当てはめています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。