expo-av には期限がある — expo-audio へどう移すか
ここまでのコードは expo-av を前提にしています。動きます。ただ、2026年に新しく組むなら、この前提はもう長くは持ちません。
Expo は expo-av の Audio・Video API を非推奨とし、それぞれ expo-audio・expo-video に置き換えました。SDK 54 では expo-av はまだ動くものの公式 SDK の一部ではなくなり、SDK 55 で削除されます(Expo ドキュメント: Audio (expo-av) )。
つまり、いま expo-av で組んだポッドキャストアプリは、次の次のメジャーアップグレードで必ず書き換えを迫られます。
私はこれを「あとで直す」の箱に入れない方を選びました。理由は単純で、音声再生はこのアプリの中枢だからです。中枢の書き換えを、UI 改修や課金導入と同じ週に抱えたくありません。移行は、他に壊れているものが何もない時期にやるのが一番安く済みます。
何が変わるか
項目 expo-av expo-audio
再生オブジェクト Audio.Sound.createAsync() で命令的に生成useAudioPlayer() フックで宣言的に取得
状態の購読 setOnPlaybackStatusUpdate のコールバックuseAudioPlayerStatus(player) がレンダリングに直結
バックグラウンド継続 staysActiveInBackground: trueshouldPlayInBackground: true
サイレントモード playsInSilentModeIOS: trueplaysInSilentMode: true(プラットフォーム接尾辞が消えた)
割り込み方針 interruptionModeIOS と interruptionModeAndroid を別々にinterruptionMode: 'doNotMix' の1本に統合
解放 unloadAsync() を手で呼ぶフックのライフサイクルに追従
表の最終行が、実は移行の最大の見返りです。この記事の後半で「Sound をアンロードし忘れてメモリリークが起きる」という失敗を扱いますが、expo-audio ではその失敗の余地自体がかなり狭くなります。手で管理していたものが、フックの寿命に乗るからです。
移行後の Audio セッション初期化
// hooks/useAudioSession.ts(expo-audio 版)
import { setAudioModeAsync } from 'expo-audio' ;
import { useEffect } from 'react' ;
export function useAudioSession () {
useEffect (() => {
setAudioModeAsync ({
// バックグラウンドでも再生を継続する(iOS では UIBackgroundModes と対で必要)
shouldPlayInBackground: true ,
// サイレントスイッチが入っていても鳴らす
playsInSilentMode: true ,
// 他アプリの音声と混ぜない。ロック画面のコントロールを掴むためにも必要
interruptionMode: 'doNotMix' ,
}). catch (( error ) => {
// 初期化に失敗しても再生自体は試みる価値がある。落とさずログに残す
console. error ( 'Failed to configure audio mode:' , error);
});
}, []);
}
app.json の UIBackgroundModes: ["audio"] は移行後も必要です。ここは変わりません。バックグラウンド再生が「2箇所の設定の積」であるという構造は、ライブラリが変わっても残ります。
移行後のプレイヤー
// components/EpisodePlayer.tsx(expo-audio 版)
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio' ;
import { View, Text, Pressable } from 'react-native' ;
import { Episode } from '../types/podcast' ;
export function EpisodePlayer ({ episode } : { episode : Episode }) {
// ローカルにあればローカルを、なければリモートを再生する
const source = episode.localPath ?? episode.audioUrl;
const player = useAudioPlayer (source);
const status = useAudioPlayerStatus (player);
const toggle = () => {
if (status.playing) {
player. pause ();
} else {
player. play ();
}
};
return (
< View >
< Text >{episode.title} </ Text >
< Pressable onPress = {toggle} >
< Text >{status.playing ? '一時停止' : '再生' } </ Text >
</ Pressable >
< Text >
{ Math . floor ( status . currentTime )} / { Math . floor ( status . duration ?? 0)} 秒
</ Text >
</ View >
);
}
setOnPlaybackStatusUpdate でコールバックを登録し、その中で setState を呼んでいた部分が丸ごと消えます。useAudioPlayerStatus が返す値がそのまま描画に使えるためです。行数以上に、追いかけるべき状態の本数が減ります。
移行時に踏んだ段差
ひとつ注意点があります。Android では setAudioModeAsync が受け取ったオブジェクトのうち shouldPlayInBackground と shouldRouteThroughEarpiece だけを反映し、interruptionMode が効かないという報告が上がっています(expo/expo #34473 )。
iOS で意図どおりに割り込みが働いたからといって、Android で同じ挙動になるとは限りません。移行後は、実機で着信を鳴らして確かめる工程を必ず挟んでください。シミュレータでは再現しない類の差です。
移すか、留まるか
状況 判断 理由
これから新規に作る 最初から expo-audio 移行コストがゼロの唯一の瞬間
SDK 52 以前で稼働中・改修予定なし 据え置き 動いているものを触らない判断も正当
SDK 54 へ上げる予定がある 同じスプリントで移行 SDK 更新の検証工数に相乗りできる
録音機能も持っている 録音を先に検証 useAudioRecorder の API 差が再生より大きい
以降のコードは expo-av のまま記述します。既存アプリを読み替えながら移行する方が多いと考えたためです。読むときは、上の対応表を横に置いていただければと思います。
RSS フィードからエピソード一覧を取得する
RSS 2.0 は XML 形式のフォーマットです。ポッドキャストの場合、Apple Podcasts が定めた拡張タグ(itunes: プレフィックス)も使われます。
// lib/rssParser.ts
import xml2js from 'react-native-xml2js' ;
import { Episode, PodcastFeed } from '../types/podcast' ;
/**
* RSS URL からポッドキャストフィードを取得してパースする
* @throws ネットワークエラー・XMLパースエラー時にエラーをスロー
*/
export async function parsePodcastFeed ( rssUrl : string ) : Promise < PodcastFeed > {
// 1. RSS XML を取得
const response = await fetch (rssUrl, {
headers: {
// ユーザーエージェントを設定しないと一部のサーバーが 403 を返す
'User-Agent' : 'PodcastApp/1.0' ,
},
});
if ( ! response.ok) {
throw new Error ( `RSS フィードの取得に失敗しました: HTTP ${ response . status }` );
}
const xmlText = await response. text ();
// 2. XML をパース
const result = await new Promise < any >(( resolve , reject ) => {
xml2js. parseString (
xmlText,
{
normalizeTags: true ,
explicitArray: true ,
explicitCharkey: true ,
},
( err : Error | null , parsed : any ) => {
if (err) reject ( new Error ( `XML のパースに失敗しました: ${ err . message }` ));
else resolve (parsed);
}
);
});
// 3. 必要なデータを抽出
const channel = result?.rss?.channel?.[ 0 ];
if ( ! channel) {
throw new Error ( 'RSS フィードの形式が正しくありません' );
}
const feedTitle = channel.title?.[ 0 ]?._ ?? channel.title?.[ 0 ] ?? '不明なポッドキャスト' ;
const feedDescription = channel.description?.[ 0 ]?._ ?? channel.description?.[ 0 ] ?? '' ;
const feedImage =
channel[ 'itunes:image' ]?.[ 0 ]?.$.href ??
channel.image?.[ 0 ]?.url?.[ 0 ] ??
'' ;
// 4. エピソードを変換
const items : any [] = channel.item ?? [];
const episodes : Episode [] = items
. map (( item : any , index : number ) => {
const audioUrl = item.enclosure?.[ 0 ]?.$.url ?? '' ;
if ( ! audioUrl) return null ; // 音声 URL がないアイテムはスキップ
const durationStr = item[ 'itunes:duration' ]?.[ 0 ]?._ ?? item[ 'itunes:duration' ]?.[ 0 ] ?? '0' ;
const duration = parseDuration (durationStr);
return {
id: item.guid?.[ 0 ]?._ ?? item.guid?.[ 0 ] ?? `episode-${ index }` ,
title: item.title?.[ 0 ]?._ ?? item.title?.[ 0 ] ?? `エピソード ${ index + 1 }` ,
description: stripHtml (
item[ 'itunes:summary' ]?.[ 0 ]?._ ?? item.description?.[ 0 ]?._ ?? item.description?.[ 0 ] ?? ''
),
audioUrl,
pubDate: new Date (item.pubdate?.[ 0 ] ?? Date. now ()),
duration,
imageUrl: item[ 'itunes:image' ]?.[ 0 ]?.$.href ?? feedImage,
};
})
. filter (( ep ) : ep is Episode => ep !== null );
return {
id: rssUrl,
title: feedTitle,
description: feedDescription,
imageUrl: feedImage,
rssUrl,
episodes,
lastUpdated: new Date (),
};
}
/** HH:MM:SS または秒数の文字列を秒数(number)に変換 */
function parseDuration ( raw : string ) : number {
if ( ! raw) return 0 ;
const parts = raw. split ( ':' ). map (Number);
if (parts. length === 3 ) return parts[ 0 ] * 3600 + parts[ 1 ] * 60 + parts[ 2 ];
if (parts. length === 2 ) return parts[ 0 ] * 60 + parts[ 1 ];
return Number (raw) || 0 ;
}
/** HTML タグを除去してプレーンテキストにする */
function stripHtml ( html : string ) : string {
return html
. replace ( /< [ ^ >] * >/ g , '' )
. replace ( /&/ g , '&' )
. replace ( /</ g , '<' )
. replace ( />/ g , '>' )
. replace ( /"/ g , '"' )
. replace ( /'/ g , "'" )
. trim ();
}
RSS パーサーで最も多いトラブルは、フィードの形式が微妙に異なることです。Apple の仕様に準拠しているフィードもあれば、itunes: タグを使わないシンプルなフィードもあります。上記の実装では ?? チェーンで複数の候補を試しているため、多くのフィードに対応できます。
エピソードのダウンロードとオフライン再生
オフライン再生は、ポッドキャストアプリを「通勤中も使える便利なアプリ」に変える重要な機能です。実装の核心は expo-file-system の createDownloadResumable ですが、進捗表示とエラーリカバリを組み合わせると少し複雑になります。
// lib/downloadManager.ts
import * as FileSystem from 'expo-file-system' ;
import { Episode } from '../types/podcast' ;
// 永続的なドキュメントディレクトリに保存する(重要)
const DOWNLOAD_DIR = FileSystem.documentDirectory + 'podcasts/' ;
async function ensureDownloadDir () : Promise < void > {
const dirInfo = await FileSystem. getInfoAsync ( DOWNLOAD_DIR );
if ( ! dirInfo.exists) {
await FileSystem. makeDirectoryAsync ( DOWNLOAD_DIR , { intermediates: true });
}
}
/**
* エピソードをダウンロードしてローカルパスを返す
* @param episode ダウンロード対象のエピソード
* @param onProgress 進捗コールバック (0.0〜1.0)
*/
export async function downloadEpisode (
episode : Episode ,
onProgress : ( progress : number ) => void
) : Promise < string > {
await ensureDownloadDir ();
// エピソード ID からファイル名を生成(特殊文字を除去)
const safeId = episode.id. replace ( / [ ^ a-zA-Z0-9-_] / g , '_' );
const ext = episode.audioUrl. split ( '.' ). pop ()?. split ( '?' )[ 0 ] ?? 'mp3' ;
const localPath = DOWNLOAD_DIR + `${ safeId }.${ ext }` ;
// すでにダウンロード済みの場合はスキップ
const existing = await FileSystem. getInfoAsync (localPath);
if (existing.exists) {
onProgress ( 1.0 );
return localPath;
}
const downloadResumable = FileSystem. createDownloadResumable (
episode.audioUrl,
localPath,
{ headers: { 'User-Agent' : 'PodcastApp/1.0' } },
( downloadProgress ) => {
const { totalBytesWritten , totalBytesExpectedToWrite } = downloadProgress;
if (totalBytesExpectedToWrite > 0 ) {
onProgress (Math. min (totalBytesWritten / totalBytesExpectedToWrite, 1.0 ));
}
}
);
const result = await downloadResumable. downloadAsync ();
if ( ! result || result.status !== 200 ) {
// ダウンロード失敗時は不完全なファイルを削除
await FileSystem. deleteAsync (localPath, { idempotent: true });
throw new Error ( `ダウンロードに失敗しました: HTTP ${ result ?. status ?? 'unknown'}` );
}
return result.uri;
}
export async function deleteEpisode ( episode : Episode ) : Promise < void > {
if ( ! episode.localPath) return ;
await FileSystem. deleteAsync (episode.localPath, { idempotent: true });
}
export async function getDownloadedSize () : Promise < number > {
const dirInfo = await FileSystem. getInfoAsync ( DOWNLOAD_DIR , { size: true });
return dirInfo.exists && 'size' in dirInfo ? dirInfo.size : 0 ;
}
ダウンロードボタンのコンポーネントでは、「未ダウンロード→ダウンロード中(進捗)→ダウンロード済み」の3状態を管理します。
// components/DownloadButton.tsx
import React, { useState } from 'react' ;
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native' ;
import { downloadEpisode, deleteEpisode } from '../lib/downloadManager' ;
import { Episode } from '../types/podcast' ;
interface Props {
episode : Episode ;
onDownloadComplete : ( localPath : string ) => void ;
onDeleteComplete : () => void ;
}
export function DownloadButton ({ episode , onDownloadComplete , onDeleteComplete } : Props ) {
const [ progress , setProgress ] = useState ( 0 );
const [ isDownloading , setIsDownloading ] = useState ( false );
const isDownloaded = !! episode.localPath;
const handleDownload = async () => {
if (isDownloading) return ;
setIsDownloading ( true );
setProgress ( 0 );
try {
const localPath = await downloadEpisode (episode, setProgress);
onDownloadComplete (localPath);
} catch (error) {
console. error ( 'ダウンロードエラー:' , error);
} finally {
setIsDownloading ( false );
setProgress ( 0 );
}
};
const handleDelete = async () => {
try {
await deleteEpisode (episode);
onDeleteComplete ();
} catch (error) {
console. error ( '削除エラー:' , error);
}
};
if (isDownloading) {
return (
< View style = {styles.progressContainer} >
< View style = {[styles.progressBar, { width: `${ progress * 100 }%` as any }]} />
<Text style={styles.progressText}>{Math.round(progress * 100)}%</Text>
</View>
);
}
return (
<TouchableOpacity
onPress={isDownloaded ? handleDelete : handleDownload}
style = {[styles.button, isDownloaded && styles.downloadedButton]}
accessibilityLabel={isDownloaded ? '保存済み・タップして削除' : 'ダウンロード' }
>
< Text style = {styles.buttonText} > {isDownloaded ? '✓ 保存済み' : '↓ 保存' } </ Text >
</ TouchableOpacity >
);
}
const styles = StyleSheet. create ({
button: {
paddingHorizontal: 12 ,
paddingVertical: 6 ,
borderRadius: 6 ,
backgroundColor: '#f0f0f0' ,
},
downloadedButton: { backgroundColor: '#e8f5e9' },
buttonText: { fontSize: 13 , fontWeight: '600' , color: '#333' },
progressContainer: {
width: 80 , height: 24 ,
backgroundColor: '#f0f0f0' , borderRadius: 4 ,
overflow: 'hidden' , justifyContent: 'center' ,
},
progressBar: { position: 'absolute' , height: '100%' , backgroundColor: '#4CAF50' },
progressText: { fontSize: 12 , fontWeight: '600' , color: '#333' , textAlign: 'center' , zIndex: 1 },
});
ダウンロードは中断される前提で組む
先ほどの downloadEpisode は、電波が切れずに最後まで届いた場合にだけ正しく動きます。実際のポッドキャストの音声ファイルは 30MB から 100MB。地下鉄に入れば途中で切れます。アプリを畳めば OS がプロセスを止めます。
createDownloadResumable という名前は、再開できることを示しています。ただ、その再開は自動では起きません。再開に必要なデータを自分で保存しておかない限り、次に開いたときは 0 バイトからやり直しになります。
SDK 54 で import の場所が変わっている
先に、移行に関わる話をひとつ。SDK 54 で expo-file-system のデフォルトエクスポートが新しいオブジェクト指向 API に差し替わり、これまでの documentDirectory や createDownloadResumable は expo-file-system/legacy へ移されました(Expo Blog: Expo File System gets a major upgrade in SDK 54 )。
// SDK 53 まで
import * as FileSystem from 'expo-file-system' ;
// SDK 54 以降で、これまでの書き方をそのまま動かす場合
import * as FileSystem from 'expo-file-system/legacy' ;
SDK 54 へ上げた直後に「FileSystem.documentDirectory が undefined になった」という形で表面化します。原因が import 一行だと分かるまでに時間を取られやすい部類です。まず import を /legacy に向けて動作を戻し、新 API への移行は別の機会に切り離すのが穏当だと考えています。
再開データを永続化する
DownloadResumable は pauseAsync() を呼んだ時点で resumeData を持ちます。これを savable() でシリアライズして保存し、次回はそこから復元します。
// lib/downloadManager.ts
import * as FileSystem from 'expo-file-system/legacy' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
import { Episode } from '../types/podcast' ;
const DOWNLOAD_DIR = FileSystem.documentDirectory + 'podcasts/' ;
const RESUME_KEY = ( id : string ) => `download:resume:${ id }` ;
type Snapshot = {
url : string ;
fileUri : string ;
options : FileSystem . DownloadOptions ;
resumeData ?: string ;
};
/**
* 再開可能なダウンロードを開始または再開する。
* 中断済みのスナップショットがあればそこから継ぎ、なければ新規に始める。
*/
export async function startOrResumeDownload (
episode : Episode ,
onProgress : ( progress : number ) => void
) : Promise < string > {
await ensureDownloadDir ();
const safeId = episode.id. replace ( / [ ^ a-zA-Z0-9-_] / g , '_' );
const ext = episode.audioUrl. split ( '.' ). pop ()?. split ( '?' )[ 0 ] ?? 'mp3' ;
const localPath = DOWNLOAD_DIR + `${ safeId }.${ ext }` ;
const existing = await FileSystem. getInfoAsync (localPath);
if (existing.exists && ! ( await AsyncStorage. getItem ( RESUME_KEY (episode.id)))) {
// 完了済み(中断スナップショットが残っていない = 最後まで届いた)
onProgress ( 1.0 );
return localPath;
}
const onWrite = ( p : FileSystem . DownloadProgressData ) => {
if (p.totalBytesExpectedToWrite > 0 ) {
onProgress (Math. min (p.totalBytesWritten / p.totalBytesExpectedToWrite, 1.0 ));
}
};
const saved = await AsyncStorage. getItem ( RESUME_KEY (episode.id));
const snapshot : Snapshot | null = saved ? JSON . parse (saved) : null ;
const downloadResumable = snapshot
? new FileSystem. DownloadResumable (
snapshot.url,
snapshot.fileUri,
snapshot.options,
onWrite,
snapshot.resumeData
)
: FileSystem. createDownloadResumable (
episode.audioUrl,
localPath,
{ headers: { 'User-Agent' : 'PodcastApp/1.0' } },
onWrite
);
const result = snapshot
? await downloadResumable. resumeAsync ()
: await downloadResumable. downloadAsync ();
if ( ! result) {
// 中断された。再開データを残して、次回に引き継ぐ
await AsyncStorage. setItem (
RESUME_KEY (episode.id),
JSON . stringify (downloadResumable. savable ())
);
throw new DownloadInterrupted (episode.id);
}
if (result.status !== 200 && result.status !== 206 ) {
// 部分応答(206)は再開時の正常系。それ以外の異常はファイルごと捨てる
await FileSystem. deleteAsync (localPath, { idempotent: true });
await AsyncStorage. removeItem ( RESUME_KEY (episode.id));
throw new Error ( `ダウンロードに失敗しました: HTTP ${ result . status }` );
}
// 完走したのでスナップショットを消す。これが「完了」の唯一の印になる
await AsyncStorage. removeItem ( RESUME_KEY (episode.id));
return result.uri;
}
export class DownloadInterrupted extends Error {
constructor ( public episodeId : string ) {
super ( `Download interrupted: ${ episodeId }` );
this .name = 'DownloadInterrupted' ;
}
}
設計上の要点をひとつだけ挙げるなら、「完了の判定にファイルの存在を使わない」ことです。
中断されたファイルもディスク上には存在します。サイズを見ても、サーバが Content-Length を返さない配信では期待値と比べられません。そこで、再開スナップショットが AsyncStorage に残っているかどうかを完了の判定に使っています。残っていれば未完、消えていれば完了。真実の在り処を1箇所に寄せる、という一般則をそのまま当てています。
206 を異常扱いしない
元のコードは result.status !== 200 を失敗としていました。再開したダウンロードはサーバから 206 Partial Content を返されるため、この条件のままでは再開が成功した瞬間に例外になります。
再開を実装したのに動かない、という相談のかなりの割合がここでした。再開を入れるなら、ステータスコードの許容範囲も同時に広げてください。
中断からの復帰をアプリの起動に組み込む
// app/_layout.tsx の中で一度だけ
import { useEffect } from 'react' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
export function useResumePendingDownloads () {
useEffect (() => {
const resumeAll = async () => {
const keys = await AsyncStorage. getAllKeys ();
const pending = keys. filter (( k ) => k. startsWith ( 'download:resume:' ));
// 一斉に再開すると帯域を食い合う。1本ずつ直列で戻す
for ( const key of pending) {
const episodeId = key. replace ( 'download:resume:' , '' );
try {
await resumeSingle (episodeId);
} catch {
// 1本の失敗で残りを止めない。次回起動でまた拾える
}
}
};
resumeAll ();
}, []);
}
再開は直列にしています。並列にすると、帯域を分け合った結果どれも完了せず、進捗バーだけが4本並んで動く、という体験になります。ユーザーが待っているのは「全部が少しずつ進むこと」ではなく「1本が終わること」です。
孤児ファイルを回収する
再開スナップショットが何らかの理由で消え、ファイルだけが残ることがあります。アプリの削除・再インストールを跨がない限り、これは静かにストレージを食い続けます。
/**
* ダウンロード済みでもなく、再開待ちでもないファイルを掃除する。
* 起動時ではなく、設定画面を開いたときなど、余裕のある場面で呼ぶ
*/
export async function sweepOrphans ( knownEpisodeIds : string []) : Promise < number > {
const files = await FileSystem. readDirectoryAsync ( DOWNLOAD_DIR );
const alive = new Set (knownEpisodeIds. map (( id ) => id. replace ( / [ ^ a-zA-Z0-9-_] / g , '_' )));
let removed = 0 ;
for ( const name of files) {
const base = name. replace ( / \. [ ^ .] +$ / , '' );
if ( ! alive. has (base)) {
await FileSystem. deleteAsync ( DOWNLOAD_DIR + name, { idempotent: true });
removed += 1 ;
}
}
return removed;
}
Zustand で再生状態を一元管理する
Zustand で再生状態を一元管理することで、どの画面からでも同じプレイヤーを操作できます。これはポッドキャストアプリのように「エピソードリスト画面で再生しながら別の画面に遷移する」ような使い方に欠かせない設計です。
// store/playerStore.ts
import { create } from 'zustand' ;
import { Audio, AVPlaybackStatus } from 'expo-av' ;
import { Episode, PlayerState } from '../types/podcast' ;
interface PlayerStore extends PlayerState {
sound : Audio . Sound | null ;
loadAndPlay : ( episode : Episode ) => Promise < void >;
togglePlayPause : () => Promise < void >;
seekTo : ( seconds : number ) => Promise < void >;
setPlaybackRate : ( rate : number ) => Promise < void >;
cleanup : () => Promise < void >;
}
export const usePlayerStore = create < PlayerStore >(( set , get ) => ({
currentEpisode: null ,
isPlaying: false ,
position: 0 ,
duration: 0 ,
playbackRate: 1.0 ,
sound: null ,
loadAndPlay : async ( episode : Episode ) => {
const { sound : existingSound } = get ();
// 新しい音声を読み込む前に既存の Sound を必ずアンロードする
if (existingSound) {
await existingSound. unloadAsync ();
}
// ダウンロード済みのローカルファイルがあれば優先して使う
const audioSource = episode.localPath
? { uri: episode.localPath }
: { uri: episode.audioUrl };
try {
const { sound } = await Audio.Sound. createAsync (
audioSource,
{
shouldPlay: true ,
progressUpdateIntervalMillis: 500 ,
rate: get ().playbackRate,
},
( status : AVPlaybackStatus ) => {
if ( ! status.isLoaded) return ;
set ({
isPlaying: status.isPlaying,
position: status.positionMillis / 1000 ,
duration: (status.durationMillis ?? 0 ) / 1000 ,
});
if (status.didJustFinish) {
set ({ isPlaying: false , position: 0 });
}
}
);
set ({ sound, currentEpisode: episode, isPlaying: true });
} catch (error) {
console. error ( '音声の読み込みに失敗しました:' , error);
throw new Error ( `再生を開始できませんでした: ${ episode . title }` );
}
},
togglePlayPause : async () => {
const { sound , isPlaying } = get ();
if ( ! sound) return ;
isPlaying ? await sound. pauseAsync () : await sound. playAsync ();
},
seekTo : async ( seconds : number ) => {
const { sound } = get ();
if ( ! sound) return ;
await sound. setPositionAsync (seconds * 1000 );
},
setPlaybackRate : async ( rate : number ) => {
const { sound } = get ();
set ({ playbackRate: rate });
if (sound) await sound. setRateAsync (rate, true );
},
cleanup : async () => {
const { sound } = get ();
if (sound) {
await sound. unloadAsync ();
set ({ sound: null , currentEpisode: null , isPlaying: false });
}
},
}));
割り込みから戻ってこられるか
バックグラウンド再生が動き始めると、次に来る報告はたいてい「電話に出たら、切った後も音が戻ってこない」です。
iOS の音声セッションは、着信・アラーム・他アプリの再生といった割り込みを受けると、こちらの再生を止めます。ここまでは正常です。問題は、割り込みが終わったあとに誰が再開するのかが決まっていない点にあります。
OS は「割り込みが終わった」ことを伝えますが、勝手に再開はしません。ネイティブでは AVAudioSession の interruption 通知を受け、shouldResume オプションを見て自分で play() を呼び直します。expo-av にはこの通知をそのまま受け取る口がありません。
AppState と再生状態の突き合わせで代替する
完全な代替にはなりませんが、実用上はこれで大半が拾えます。
// hooks/useInterruptionRecovery.ts
import { useEffect, useRef } from 'react' ;
import { AppState, AppStateStatus } from 'react-native' ;
import { usePlayerStore } from '../store/playerStore' ;
export function useInterruptionRecovery () {
// 「ユーザーが再生を意図していたか」を保持する。OS の再生状態とは別物
const intendedToPlay = useRef ( false );
useEffect (() => {
const unsubscribe = usePlayerStore. subscribe (( state ) => {
// ユーザー操作による再生・一時停止のときだけ意図を更新する
if (state.lastChangeSource === 'user' ) {
intendedToPlay.current = state.isPlaying;
}
});
return unsubscribe;
}, []);
useEffect (() => {
const handleChange = async ( next : AppStateStatus ) => {
if (next !== 'active' ) return ;
const { sound , isPlaying , resume } = usePlayerStore. getState ();
if ( ! sound) return ;
const status = await sound. getStatusAsync ();
if ( ! status.isLoaded) return ;
// 「鳴らすつもりだったのに、OS 側では止まっている」= 割り込みで落とされた
if (intendedToPlay.current && ! status.isPlaying && ! isPlaying) {
await resume ();
}
};
const sub = AppState. addEventListener ( 'change' , handleChange);
return () => sub. remove ();
}, []);
}
肝は intendedToPlay を「OS の再生状態」と切り離して持っている点です。
isPlaying をそのまま復帰の根拠にすると、ユーザーが自分で一時停止してホーム画面に戻り、戻ってきた瞬間に勝手に再生が始まる、という最悪の挙動になります。止めたいから止めたのです。意図と状態は、別の変数で持たなければ区別できません。
ヘッドホンを抜いたとき
もうひとつ、審査でも指摘されうる挙動があります。イヤホンを抜いた瞬間に、音がスピーカーから鳴り出す。カフェや電車の中で起きると、実害があります。
iOS はルート変更時にこちらの再生を一時停止しますが、expo-av の設定次第では素通りします。interruptionModeIOS: INTERRUPTION_MODE_IOS_DO_NOT_MIX を入れておくことは、ロック画面のコントロールを掴むためだけでなく、この事故を防ぐ側面もあります。
expo-audio では interruptionMode: 'doNotMix' が同じ役割を担います。ただし前述のとおり Android 側の反映には報告済みの制約があるため、Android 実機での確認は省けません。
検証は実機で、この順に
シミュレータでは着信もヘッドホンの抜き差しも再現できません。実機で次の順に触るのが最短だと考えています。
# 操作 期待する挙動 よくある実際
1 再生中に画面ロック 再生が続く 数秒で止まる(UIBackgroundModes 漏れ)
2 再生中に着信を受けて切る 切った後に再生が戻る 止まったまま(割り込み復帰の未実装)
3 自分で一時停止 → ホーム → 戻る 止まったまま 勝手に再生が始まる(意図と状態の混同)
4 再生中にイヤホンを抜く 一時停止する スピーカーから鳴り出す
5 再生中に別アプリで動画を再生 こちらが止まる 両方鳴る(doNotMix 未設定)
この5つは、どれも1分で試せます。そして、5つ全部を通る実装は、私の経験では最初から書けたことがありません。毎回どれかで落ちます。だからこそ、リリース前のチェックリストとして手元に置いておく価値があると考えています。
再生速度変更 UI の実装
// components/PlaybackRateSelector.tsx
import React from 'react' ;
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native' ;
const RATES = [ 0.75 , 1.0 , 1.25 , 1.5 , 2.0 ];
interface Props {
currentRate : number ;
onRateChange : ( rate : number ) => void ;
}
export function PlaybackRateSelector ({ currentRate , onRateChange } : Props ) {
return (
< View style = {styles.container} >
{ RATES . map (( rate ) => (
< TouchableOpacity
key = {rate}
onPress = {() => onRateChange ( rate )}
style = {[styles.button, currentRate === rate && styles.activeButton]}
accessibilityLabel={`再生速度 ${rate}倍速`}
accessibilityState={{ selected: currentRate === rate }}
>
< Text style = {[styles.label, currentRate === rate && styles.activeLabel]}>
{rate === 1.0 ? 'x1' : `x${ rate }` }
</ Text >
</ TouchableOpacity >
))}
</ View >
);
}
const styles = StyleSheet. create ({
container: { flexDirection: 'row' , gap: 8 , justifyContent: 'center' , paddingVertical: 8 },
button: { paddingHorizontal: 12 , paddingVertical: 6 , borderRadius: 16 , backgroundColor: '#f0f0f0' },
activeButton: { backgroundColor: '#1a73e8' },
label: { fontSize: 13 , fontWeight: '600' , color: '#555' },
activeLabel: { color: '#fff' },
});
続きから聴けるようにする
ポッドキャストアプリを音楽プレイヤーと分けている機能を1つだけ挙げるなら、再生位置の記憶だと思います。
1時間の対談を、通勤の往路で 20 分、復路で 25 分、翌朝に残りを聴く。この使い方が成立しなければ、ユーザーは戻ってきません。そして再生位置の保存は、素直に書くとほぼ確実に失敗します。
毎秒書くと、書き込みが詰まる
setOnPlaybackStatusUpdate は、既定でおよそ 500ms ごとに呼ばれます。ここで AsyncStorage に書くと、1時間の再生で 7,200 回の書き込みが走ります。
// ❌ 状態更新のたびに永続化する
sound. setOnPlaybackStatusUpdate (( status ) => {
if ( ! status.isLoaded) return ;
AsyncStorage. setItem ( `pos:${ episodeId }` , String (status.positionMillis));
});
体感できるほど重くはならないことも多く、だからこそ気づかないまま出荷されます。ただ、書き込みが詰まった状態でアプリを畳むと、最後の書き込みが完了しないことがあります。「聴いていたのに位置が戻る」という報告の一因がここです。
間引いて書き、要所で必ず書く
// lib/positionStore.ts
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const KEY = ( id : string ) => `pos:${ id }` ;
const SAVE_INTERVAL_MS = 5000 ;
// 終わり際まで聴いたエピソードは、次回は先頭から始める
const NEAR_END_THRESHOLD_MS = 30_000 ;
let lastSavedAt = 0 ;
export async function savePosition (
episodeId : string ,
positionMillis : number ,
durationMillis : number ,
options : { force ?: boolean } = {}
) : Promise < void > {
const now = Date. now ();
if ( ! options.force && now - lastSavedAt < SAVE_INTERVAL_MS ) return ;
lastSavedAt = now;
// 最後まで聴き切った扱いなら、位置を消して「完了」にする
if (durationMillis > 0 && durationMillis - positionMillis < NEAR_END_THRESHOLD_MS ) {
await AsyncStorage. removeItem ( KEY (episodeId));
return ;
}
await AsyncStorage. setItem ( KEY (episodeId), String (Math. floor (positionMillis)));
}
export async function loadPosition ( episodeId : string ) : Promise < number > {
const raw = await AsyncStorage. getItem ( KEY (episodeId));
return raw ? Number (raw) : 0 ;
}
そして、間引いた分を取り戻す場所を用意します。
// app/_layout.tsx
import { AppState } from 'react-native' ;
import { usePlayerStore } from '../store/playerStore' ;
import { savePosition } from '../lib/positionStore' ;
AppState. addEventListener ( 'change' , ( next ) => {
if (next === 'active' ) return ;
// 畳まれる・落とされる瞬間だけは、間引きを無視して必ず書く
const { currentEpisode , positionMillis , durationMillis } = usePlayerStore. getState ();
if (currentEpisode) {
savePosition (currentEpisode.id, positionMillis, durationMillis, { force: true });
}
});
5秒ごとの間引き保存に加えて、background へ移る瞬間に force: true で1回。この2段構えにすると、最悪でも失う位置は5秒以内に収まり、通常の離脱では1秒も失いません。
終わり際の30秒を「完了」にする理由
NEAR_END_THRESHOLD_MS で、残り30秒を切ったエピソードは位置を保存せず消しています。
エピソードの末尾は、たいてい次回予告やクレジットです。ここで止めた人は「聴き終えた」のであって「途中」ではありません。にもかかわらず位置を残すと、次に開いたとき残り10秒から再生が始まり、ユーザーは自分で先頭に戻す操作を強いられます。
閾値は 30 秒と決め打ちにしていますが、これは番組の長さによって妥当性が変わります。5分のエピソードで残り30秒は「1割残っている」ことになる。長さに対する比率と、絶対秒数の小さい方を採るのが実際には近いと考えています。この種の判断は、数字を1つ置いて終わりにせず、自分の番組の長さの分布を見て決める方が納得のいく結果になります。
ロック画面に何も出ないという相談
冒頭のアーキテクチャで「ロック画面対応」を挙げました。ここで正直にお伝えしておかなければならないことがあります。
expo-av には、ロック画面に曲名・番組名・アートワーク・スキップボタンを表示するための API がありません。iOS の Now Playing 情報は MPNowPlayingInfoCenter、リモート操作は MPRemoteCommandCenter が担いますが、expo-av はそのどちらも公開していません。
音声セッションを掴んでいるので、ロック画面に最低限の再生・一時停止は現れます。しかし、番組名は出ません。アートワークも出ません。「30秒進む」「15秒戻る」も出ません。ポッドキャストアプリで最も使われるボタンが、ちょうど出ない部分に入っています。
ここが分岐点になる
作るもの 妥当な選択 根拠
自分用・社内用の音声プレイヤー expo-audio のままロック画面の作り込みが要件でない
数分の音声を再生する補助機能 expo-audio のままロック画面から操作する場面が来ない
公開するポッドキャスト・音楽アプリ react-native-track-playerNow Playing・スキップ秒数・キューが要件になる
Rork で骨格を作って育てる 骨格は Expo、再生層だけ差し替え 生成コードの資産を捨てずに済む
最後の行が、Rork を使う場合の現実解だと考えています。
Rork に「RSS を読んでエピソード一覧を出し、再生画面へ遷移する」と伝えれば、画面・ナビゲーション・型定義・状態管理の骨格は数分で出てきます。その骨格の価値は本物です。捨てる必要はありません。
差し替えるのは再生層だけです。この記事で Zustand ストアを噛ませ、再生の実体を playerStore の内側に押し込めているのは、そのためでもあります。UI は usePlayerStore としか話しません。中身が expo-av でも react-native-track-player でも、画面側のコードは変わらない。
生成 AI が書いた骨格の上で、差し替える可能性が高い1箇所だけを境界で包んでおく。これは私が Rork や Claude Code で骨格を出させるときに、毎回やっていることです。全部を自分で書き直すのは遅すぎるし、全部を生成のまま使うと後で身動きが取れなくなる。境界を1本引くのが、ちょうど中間の落とし所になります。
保存領域が溢れる日のために
この記事の後半に「cacheDirectory ではなく documentDirectory を使う」という項目があります。翌日エピソードが消えている問題への対処として、これは正しい。ただ、これだけでは足りません。
documentDirectory(iOS の Documents)は、iCloud バックアップの対象です。ここに 2GB のエピソードが溜まると、その 2GB がユーザーの iCloud バックアップを圧迫します。無料枠 5GB の人にとっては、それだけで写真のバックアップが止まりかねない量です。
そして Apple のデータ保存ガイドラインは、再ダウンロードできるコンテンツを Documents に無印で置くことを想定していません。再取得可能なデータは Caches に置くか、バックアップ除外の印を付けることが求められます。ポッドキャストのエピソードは、まさに「サーバから再取得できるデータ」です。
消える場所と、膨らむ場所の間
置き場所 OS に消されるか iCloud バックアップ ポッドキャストに向くか
cacheDirectory容量逼迫時に消される 対象外 ×(ユーザーが意図して保存したものが消える)
documentDirectory(無印)消されない 対象 △(バックアップを圧迫する)
documentDirectory + 除外印消されない 対象外 ◎(ただし Expo の公開 API では印を付けられない)
三段目が理想で、そして三段目は expo-file-system の公開 API だけでは実現できません。isExcludedFromBackup を立てるにはネイティブ側に触れる必要があり、config plugin を書くか、対応するライブラリを挟むことになります。
印を付けられないなら、量を管理する
config plugin を書くのが重いと感じるなら、もうひとつの道があります。バックアップに乗ることを受け入れた上で、乗る量に上限を設ける。
// lib/storageBudget.ts
import * as FileSystem from 'expo-file-system/legacy' ;
const DOWNLOAD_DIR = FileSystem.documentDirectory + 'podcasts/' ;
const BUDGET_BYTES = 1.5 * 1024 * 1024 * 1024 ; // 1.5GB を上限とする
type Entry = { path : string ; size : number ; lastPlayedAt : number };
/**
* 上限を超えた分だけ、最後に聴いた時刻が古い順に消す。
* 再生中のエピソードは保護する
*/
export async function evictIfOverBudget (
entries : Entry [],
protectedPath ?: string
) : Promise < string []> {
const total = entries. reduce (( sum , e ) => sum + e.size, 0 );
if (total <= BUDGET_BYTES ) return [];
// 古く聴いたものから捨てる。未再生(lastPlayedAt = 0)は最後まで残す
const candidates = entries
. filter (( e ) => e.path !== protectedPath && e.lastPlayedAt > 0 )
. sort (( a , b ) => a.lastPlayedAt - b.lastPlayedAt);
const evicted : string [] = [];
let freed = 0 ;
for ( const entry of candidates) {
if (total - freed <= BUDGET_BYTES ) break ;
await FileSystem. deleteAsync (entry.path, { idempotent: true });
freed += entry.size;
evicted. push (entry.path);
}
return evicted;
}
並び替えの条件に注目してください。未再生のエピソード(lastPlayedAt === 0)を候補から除いています。
ユーザーがダウンロードしたのは、これから聴くためです。まだ聴いていないものを容量のために消すのは、ダウンロードという操作の意味を裏切ります。逆に、聴き終わったエピソードは消してよい。同じ「自動削除」でも、どちらから消すかで体験は正反対になります。
そして、自動で消した事実は必ずユーザーに見せてください。設定画面に使用量と「聴き終わった○件を自動削除しました」の一行があるだけで、「勝手に消えた」が「そういう仕組みだった」に変わります。技術的には同じ削除です。伝えるかどうかだけが違います。
よくある実装ミスとその修正パターン
実際に開発者が詰まりやすいポイントを3つ紹介します。どれも「動いているように見えるが、特定の状況で壊れる」タイプの問題です。
ミス 1: setAudioModeAsync を再生のたびに呼んでいる
// ❌ 悪い例:再生のたびに Audio セッションを設定し直す
const playEpisode = async ( episode : Episode ) => {
await Audio. setAudioModeAsync ({ staysActiveInBackground: true }); // 毎回はNG
const { sound } = await Audio.Sound. createAsync ({ uri: episode.audioUrl });
};
// ✅ 正しい例:app/_layout.tsx の useEffect で一度だけ呼ぶ
// → useAudioSession() フックを参照
setAudioModeAsync を頻繁に呼ぶと、iOS の Audio Session がリセットされて別のアプリの音声が予期せず復活することがあります。
ミス 2: Sound オブジェクトをアンロードせず新しい音声を読み込む
// ❌ 悪い例:前の Sound が解放されずメモリリークが起きる
const playNext = async ( episode : Episode ) => {
const { sound } = await Audio.Sound. createAsync ({ uri: episode.audioUrl });
setCurrentSound (sound); // 古い sound は?
};
// ✅ 正しい例:新しい Sound を作る前に必ず unloadAsync する
const playNext = async ( episode : Episode ) => {
if (currentSound) {
await currentSound. unloadAsync (); // メモリ解放
}
const { sound } = await Audio.Sound. createAsync ({ uri: episode.audioUrl });
setCurrentSound (sound);
};
長時間の使用でアプリが重くなる原因のほとんどがこれです。Sound オブジェクトは必ず一対一で管理してください。
ミス 3: cacheDirectory にダウンロードファイルを保存している
// ❌ 悪い例:OS がストレージ不足時に削除する可能性がある
const localPath = FileSystem.cacheDirectory + 'episode.mp3' ;
// ✅ 正しい例:documentDirectory を使う
// cacheDirectory は一時ファイル用。ユーザーが意図的に保存したファイルには使わない
const localPath = FileSystem.documentDirectory + 'podcasts/episode.mp3' ;
翌日アプリを開いたらダウンロードしたエピソードが消えていた、という問題の原因はほぼ全てこれです。
App Store・Google Play 審査で必要な対応
iOS: バックグラウンドモードの説明
App Store Connect の審査では、バックグラウンドモードを使っているアプリに対して「なぜバックグラウンドが必要か」を確認されることがあります。審査メモ(Review Notes)に「このアプリはポッドキャストプレイヤーです。ユーザーが他のアプリを使用中や画面をロック中でも音声が継続します」と記載しておくと審査がスムーズです。
UIBackgroundModes: ["audio"] が app.json に設定されていれば、EAS Build でのビルド時に自動的に Info.plist に反映されます。
Android: Foreground Service の権限宣言
{
"expo" : {
"android" : {
"permissions" : [
"FOREGROUND_SERVICE" ,
"FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
}
}
}
expo-av が内部的に使用しますが、明示的に宣言しておくことで Google Play の審査でのポリシー違反指摘を防げます。
プライバシーポリシーの書き方については、Rorkで作ったアプリのプライバシーポリシー もご覧いただけると幸いです。ダウンロード履歴や再生履歴を保存する以上、記載の対象になります。
本番に出す前に、ここだけは
ポッドキャストアプリの実装で難しいのは、個々の技術要素ではありません。RSS の解析も、ダウンロードも、再生も、単体なら難しくない。難しいのは、それらが「中断される」「割り込まれる」「消される」という前提の下で、それでも壊れないように組むことです。
この記事で扱った判断を、もう一度並べておきます。
論点 この記事の立場
expo-av か expo-audio か 新規なら expo-audio。SDK 55 で expo-av は消える
ダウンロードの完了判定 ファイルの存在ではなく、再開スナップショットの不在で判定する
割り込みからの復帰 「ユーザーの意図」を OS の再生状態と別の変数で持つ
ロック画面のメタデータ 公開アプリなら react-native-track-player。再生層だけ境界で包む
ダウンロードの保存先 documentDirectory + 容量予算。聴き終わったものから消す
再生位置の保存 5秒間引き + 離脱時に強制保存の2段構え
もし1つだけ今日試すとしたら、前述の実機チェック5項目を通してみてください。着信を受けて切る。イヤホンを抜く。自分で止めてから戻る。すでに動いているアプリをお持ちなら、5分で2つか3つは見つかるはずです。私はいつも見つけます。
Rork で骨格を出させる場合も、順番は同じだと考えています。まず RSS を1本読んで一覧を出す。次に再生を1本通す。そこまで動いてから、中断・割り込み・容量の3つを1つずつ潰していく。最初から全部を指示すると、生成されたコードのどこが自分の要件を満たしていないのか、判断できなくなります。
基本的なオーディオ再生の仕組みについては、Rork で音楽プレイヤーアプリを作る も合わせてご覧いただけると、今回の実装との違いがより鮮明になると思います。オフラインデータの設計全般については、Rork Max で実装するオフライン・ファースト・アーキテクチャ が、今回のダウンロード管理の考え方を別の角度から補ってくれるはずです。
音声を扱うアプリは、ユーザーが画面を見ていない時間が最も長いアプリでもあります。見えないところで壊れないことに、ほとんどの手間がかかる。地味ですが、そこに手を掛けた分だけ、ユーザーは静かに使い続けてくれます。私自身まだ学びの途中ですが、共に手を動かしていけたら嬉しいです。