• #responsive
  • #mobile
  • #web-audio-api
  • #localstorage
  • #css
  • #eurekapu
開発eurekapu-nuxt4メモ

ナレーションビューアのモバイル対応

ナレーションビューアをスマホで開いたら、メッセージラインの左カラムが画面の半分を占めて本文が読めなかった。PCで作り込んだレイアウトがモバイルでは全く機能していない。Chrome DevToolsのデバイスモードで画面幅を縮めていくと、タブレットでも微妙に窮屈で、PCでだけちょうどいい。3パターンのブレイクポイントを切って、1つずつ潰していった。

レスポンシブ3パターンの設計

画面幅で3段階に分岐させた。

スマホ(~768px)

メッセージラインの左カラム(話者アイコン・名前)を非表示にした。スマホの画面幅で2カラムを維持すると、本文が1行あたり10文字程度しか入らない。左カラムを消して本文をフル幅で表示するだけで、可読性が一気に上がった。

画像もフル幅に広げた。PC向けに最大幅を制限していたが、スマホでは余白が邪魔になる。戻るリンクは「← 会計・簿記の基礎知識」というテキストを「←」の矢印アイコンだけに切り替えた。

タブレット(769px~1024px)

左カラムは残すが、比率を 1fr 2fr に変更。PCの 1fr 3fr だと左が狭すぎてアイコンが潰れ、1fr 1fr だと右の本文領域が足りない。DevToolsでiPadのビューポートを開きながら比率を3回変えて、1fr 2fr に落ち着いた。

PC(1025px~)

従来通り。変更なし。

/* 核心部分だけ抜粋 */
@media (max-width: 768px) {
  .message-line .speaker-column {
    display: none;
  }
  .message-line .content-column {
    grid-column: 1 / -1;
  }
  .narration-image {
    max-width: 100%;
  }
}

@media (min-width: 769px) and (max-width: 1024px) {
  .message-line {
    grid-template-columns: 1fr 2fr;
  }
}

パンくずリストの追加

ナレーションビューアでコンテンツを読んでいるとき、「元のトピック一覧に戻りたい」という導線がなかった。ブラウザの戻るボタンに頼っていたが、途中でページ内遷移を挟むと戻り先がずれる。

パンくずリスト「← 会計・簿記の基礎知識」を上部に固定配置した。スマホでは矢印アイコンだけにして、タップ領域は44px以上を確保した。

Miller Viewerのセクションナビゲーション

Miller Column Viewerをスマホで使うと、セクション間の移動がつらい。PCではサイドバーに目次が常時表示されるが、スマホではサイドバーを出す余裕がない。

「← 前へ / 次へ →」ボタンをコンテンツ下部に配置した。現在のセクション番号と全体数(例: 3 / 7)も表示して、今どこにいるか分かるようにした。

画像トランジション(crossfade)の除去

当初、セクション切替時に画像をcrossfadeで切り替えていた。フェードイン・フェードアウトで滑らかに見える――と思っていたが、実際に使ってみると逆効果だった。

教材の画像は、前の画像との差分で「どこが変わったか」を読み取るものが多い。crossfadeが入ると、切替の瞬間に両方の画像が半透明で重なり、差分の把握を妨げる。0.3秒のトランジションでも、目が前の画像の位置を記憶する前に新しい画像が溶け込んでくる。

トランジションを全て除去して即時切替に変更した。パッと切り替わる方が「あ、ここが変わった」と視覚的に捉えやすい。見た目の滑らかさよりも、学習体験としての情報伝達を優先した。

LocalStorage進捗保存

ナレーションビューアで50行目まで読み進めたところでブラウザを閉じると、次に開いたとき1行目に戻される。これが地味にストレスだった。

LocalStorageにコンテンツIDと現在の行番号を保存するようにした。ページを開いたとき、保存された行番号があればそこまでスクロールして再開する。

const STORAGE_KEY_PREFIX = 'narration-progress-'

const saveProgress = (contentId: string, lineIndex: number) =>
  localStorage.setItem(`${STORAGE_KEY_PREFIX}${contentId}`, String(lineIndex))

const loadProgress = (contentId: string): number =>
  Number(localStorage.getItem(`${STORAGE_KEY_PREFIX}${contentId}`) ?? 0)

保存タイミングは行の切替時。スクロールイベントではなく、ナレーション再生で次の行に進んだタイミングで書き込む。スクロールイベントだと1秒間に何十回も発火してしまう。

モバイル倍速再生の音声途切れ修正

これが一番手こずった。

PC版では、話者ごとにステレオパニング(左/右/中央)を振り分けるために、Web Audio APIのパイプラインを通していた。

<audio> → createMediaElementSource → StereoPannerNode → destination

PCでは倍速再生(1.5x、2x)でも問題なく動く。しかしモバイルでは2x再生にすると音声がブツブツ途切れた。

原因の切り分け

最初はネットワークの問題を疑った。CDNからの配信だしキャッシュも効いているはずだが、モバイル回線だと帯域が足りないのかもしれない。しかし、Wi-Fi環境でも途切れる。

次にAudioContextのバッファサイズを疑った。モバイルブラウザはバッファサイズが小さく、倍速再生でデコードが間に合わない可能性がある。AudioContextのsampleRateを確認したが、PC・モバイルとも48000Hzで差はない。

デバッグを重ねて分かったのは、createMediaElementSourceでAudioContextに接続した時点で、音声のデコードとレンダリングがWeb Audio APIのスレッドに移る、ということだった。モバイルブラウザはWeb Audio APIの処理能力がPCより低く、倍速再生のデコード負荷に追いつけない。

解決策: モバイルではパイプラインをスキップ

モバイルではステレオパニングを諦め、<audio>要素のplaybackRateだけで再生速度を制御するようにした。

const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent)

const setupAudio = (audioEl: HTMLAudioElement, pan: number) => {
  if (isMobile) {
    // モバイル: Web Audio APIを通さず直接再生
    return { cleanup: () => {} }
  }
  // PC: StereoPannerNodeでパニング
  const ctx = new AudioContext()
  const source = ctx.createMediaElementSource(audioEl)
  const panner = ctx.createStereoPanner()
  panner.pan.value = pan
  source.connect(panner).connect(ctx.destination)
  return { cleanup: () => ctx.close() }
}

モバイルでステレオパニングが効かなくなる代わりに、倍速再生が安定した。スマホのスピーカーで左右の定位を聞き分けられる場面はほぼないので、実用上の影響はない。

デプロイとCDNキャッシュ確認

ローカルで全パターンの動作を確認した後、本番環境にデプロイした。

cd apps/web && pnpm deploy:cloudflare

デプロイ後、CDNキャッシュが古いCSSを返さないか確認した。Cloudflare Pagesはデプロイごとにキャッシュが切り替わるので、通常は問題ない。ただし、音声ファイルはR2から配信しているため、R2側のキャッシュが残っている可能性がある。実際にスマホでアクセスして、新しいCSSが適用されていること、倍速再生で音が途切れないことを確認した。

Chrome DevToolsで3パターン全確認

最後にChrome DevToolsのデバイスモードで3パターン全てを確認した。

  • iPhone SE(375px): メッセージライン左カラム非表示、画像フル幅、戻るリンク矢印のみ
  • iPad(768px): メッセージライン 1fr 2fr、パンくずリストのテキスト表示
  • PC(1440px): 従来通りのレイアウト

実機のiPhoneでも倍速再生を試し、音声が途切れないことを確認した。

振り返り

今回の作業で一番時間を使ったのは、モバイルの倍速再生の音声途切れだった。ネットワーク → バッファサイズ → AudioContext → createMediaElementSourceと、原因の候補を1つずつ潰していく過程で3時間近く使った。

結局、「モバイルではWeb Audio APIパイプラインをスキップする」という判断に至ったが、これは最初から選択肢にあったわけではない。ステレオパニングを全プラットフォームで統一したいという思いがあって、モバイルだけ挙動を変えることに抵抗があった。しかし、スマホのスピーカーでパニングを聞き分ける人はまずいない、という現実を受け入れたら一瞬で解決した。

crossfadeの除去も、「滑らかなトランジション=良いUX」という思い込みを手放すのに少し時間がかかった。教材コンテンツでは、前後の画像の差分を瞬時に把握できることの方がはるかに価値がある。