再生ボタンを押したのに、音が出ませんでした。
指を離して一拍おいても、画面は何も変わりませんでした。
ヒーリング音源アプリの再生画面を整えていた夜、私は自分のアプリでこの「間」を見て、胃のあたりが少し重くなりました。バグではありません。回線が細いところで、音声ファイルの先頭を受け取るまでに時間がかかっているだけでした。けれど画面が無反応だと、使う方には壊れているのと区別がつきません。ボタンを二度三度と押され、そのたびに再生要求が重なっていきました。
最初にお伝えしたいのは、直すべきは通信ではなく、待っているあいだの画面の状態だということです。
待ち時間を「状態」として持つ
再生ボタンを押してから音が出るまでの時間は、私たちの側では短くできません。そこで、待っている最中を画面の状態として明示的に持ちます。私は次の4つに分けています。
| 状態 | 意味 | ボタンの見た目 |
|---|---|---|
| idle | まだ押されていない | 再生アイコン |
| waiting | 押された。音はまだ | 回転表示(押せない) |
| playing | 音が出ている | 一時停止アイコン |
| failed | 時間切れまたは読み込み失敗 | やり直しの案内 |
ポイントは waiting を playing と別に持つことです。「再生中のつもり」の状態が長いと、二重押しの原因になります。
expo-audio の状態から waiting を判定する
expo-audio の useAudioPlayerStatus は、プレイヤーの状態をオブジェクトで返します。この中の isLoaded(読み込み完了か)、isBuffering(バッファ待ちか)、playing(再生中か)を使います。フィールドの名前と意味は、お使いの SDK のバージョンの型定義でご確認ください。
まず、状態を決める小さな関数を分けておきます。画面から切り離しておくと、読み返すときに迷いません。
// 何を解決するか: 「押したのに音が出ない間」を画面の状態として決める
type PlayUiState = 'idle' | 'waiting' | 'playing' | 'failed';
export function decidePlayUiState(args: {
requested: boolean; // 利用者が再生を押したか
playing: boolean; // status.playing
failed: boolean; // 時間切れ・読み込み失敗
}): PlayUiState {
if (args.failed) return 'failed';
if (args.playing) return 'playing';
if (args.requested) return 'waiting'; // 押された後、音が出るまで
return 'idle';
}requested を自分で持つのが肝です。プレイヤー側の isBuffering だけに頼ると、押した瞬間はまだ false の瞬間があり、その一拍で画面が無反応に見えます。押した事実を先に記録しておけば、押した瞬間から waiting にできます。
400ms 待ってから表示する
回転表示をすぐに出すと、回線が良いときには一瞬ちらついて、かえって落ち着かない画面になります。私は 400ms を過ぎても音が出ないときだけ、回転表示を出すようにしています。この 400ms は計測値ではなく、私の好みで置いた設計値です。
import { useEffect, useRef, useState } from 'react';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
const SHOW_SPINNER_AFTER_MS = 400; // ちらつき防止
const GIVE_UP_AFTER_MS = 8000; // 時間切れ
export function useStreamingPlayback(url: string) {
const player = useAudioPlayer({ uri: url });
const status = useAudioPlayerStatus(player);
const [requested, setRequested] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [failed, setFailed] = useState(false);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearTimers = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
};
const start = () => {
if (requested) return; // 二重押しを無視
setFailed(false);
setRequested(true);
player.play();
timers.current.push(setTimeout(() => setShowSpinner(true), SHOW_SPINNER_AFTER_MS));
timers.current.push(setTimeout(() => setFailed(true), GIVE_UP_AFTER_MS));
};
// 音が出た・または失敗したらタイマーを片付ける
useEffect(() => {
if (status.playing || failed) {
clearTimers();
setShowSpinner(false);
if (failed) setRequested(false); // やり直せる状態へ戻す
}
}, [status.playing, failed]);
useEffect(() => clearTimers, []); // 画面を離れたら必ず片付ける
return { requested, showSpinner, failed, playing: status.playing, start };
}このコードで私が気をつけたのは三点です。start の先頭で二重押しを止めること。音が出たらタイマーを片付けること。画面を離れるときにも片付けること。最後の一つを忘れると、離れた画面の setState が後から走ります。
8秒で見切りをつけ、やり直しを渡す
待ち続けさせないために、時間切れを置きます。8秒という数字も設計値です。短すぎると、混み合った回線の方を失敗扱いにしてしまい、長すぎると、無反応と区別がつきません。
失敗したときに出す文言は、原因を断定しません。「読み込みに時間がかかっています。もう一度お試しください」と、次の行動を一つだけ示します。通信が悪いのか、配信側なのか、画面からは分からないからです。
私はこの画面で、時間切れのあとに自動で再試行しないことにしました。自動再試行を入れた版では、回線が不安定な場所で再生要求が裏で積み重なり、しばらくして複数の音が重なって鳴ったことがありました。いまは、再試行は利用者が押した一回だけにしています。待たせる時間は決めても、やり直しの引き金は利用者に渡します。 この線引きだけは、画面を作り直すたびに残しています。
落とし穴
- 前の曲のプレイヤーを解放しないまま新しいプレイヤーを作ると、再生の不具合が出ることがあります。こちらは expo-audio の createAudioPlayer が40回で止まる件 にまとめました。
- 回転表示の見せ方そのものは、スケルトン画面による読み込み体験の設計 とも考え方がつながります。
- 環境音のように切れ目なく流すアプリでは、先頭の読み込みに加えてループ境界の設計も要ります。ヒーリングアプリの環境音ループの落とし穴 もあわせてご覧ください。
次にやること
まず、お使いの再生ボタンに requested を一つ足して、押した瞬間に見た目が変わるかどうかを確かめてみてください。回線を意図的に細くした状態(端末の通信制限や機内モードの切り替え)で試すと、「無反応に見える間」がよく分かります。私もまずそこから直しました。