【2025年最新版】radiko APIでラジオアプリを作ろう!Kotlin + Jetpack Composeで実装する完全ガイド
以前、radiko APIの基本的な認証方法について紹介しましたが、今回はその続編として、KotlinとJetpack Composeを使ってAndroidアプリでradikoのラジオ再生機能を実装する方法を解説します。
radikoは日本全国のラジオ番組をインターネット経由で聴けるサービスです。そのAPIを使えば、自分だけのオリジナルラジオプレーヤーアプリが作れます。この記事では、認証から再生まで、実際に動くコードと共に詳しく解説していきます。
radiko APIの基本仕様と認証フロー
radikoでラジオを聴くには、認証処理が必要です。2025年現在でも基本的な認証フローは変わっていません。
認証の流れ
まず、Auth1で認証トークンとキー情報を取得します。次に、取得したキー情報を元にPartialKeyを生成し、Auth2で最終認証を行います。認証が完了すると、エリア情報が取得でき、ストリーミングURLにアクセスできるようになります。
radikoは現在HLS(HTTP Live Streaming)形式で配信されており、m3u8プレイリストを通じて音声ストリームを受信します。以前はFlashベースでしたが、現在はHTML5ベースの配信に完全移行しています。
Android環境のセットアップ
まず、必要なライブラリを追加します。build.gradle.kts(Module)に以下を記述しましょう。
dependencies {
// Jetpack Compose
implementation("androidx.compose.ui:ui:1.6.0")
implementation("androidx.compose.material3:material3:1.2.0")
implementation("androidx.activity:activity-compose:1.8.2")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Network
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ExoPlayer (音声再生用)
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
}
AndroidManifest.xmlにインターネット権限を追加します。
<uses-permission android:name="android.permission.INTERNET" />
radiko認証クラスの実装
radikoの認証処理を行うクラスを作成します。
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
data class AuthResult(
val authToken: String,
val areaId: String
)
class RadikoAuthenticator {
private val client = OkHttpClient()
// radiko認証キー(固定値)
private val authKey = "bcd151073c03b352e1ef2fd66c32209da9ca0afa"
suspend fun authenticate(): Result<AuthResult> = withContext(Dispatchers.IO) {
try {
// Step 1: Auth1で認証トークンとキー情報を取得
val auth1Result = performAuth1() ?: return@withContext Result.failure(
Exception("Auth1 failed")
)
// Step 2: PartialKeyを生成
val partialKey = generatePartialKey(
auth1Result.keyOffset,
auth1Result.keyLength
)
// Step 3: Auth2で最終認証
val areaId = performAuth2(auth1Result.authToken, partialKey)
?: return@withContext Result.failure(Exception("Auth2 failed"))
Result.success(AuthResult(auth1Result.authToken, areaId))
} catch (e: Exception) {
Result.failure(e)
}
}
private fun performAuth1(): Auth1Result? {
val request = Request.Builder()
.url("https://radiko.jp/v2/api/auth1")
.header("X-Radiko-App", "pc_html5")
.header("X-Radiko-App-Version", "0.0.1")
.header("X-Radiko-User", "dummy_user")
.header("X-Radiko-Device", "pc")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val authToken = response.header("X-Radiko-AuthToken") ?: return null
val keyOffset = response.header("X-Radiko-KeyOffset")?.toInt() ?: return null
val keyLength = response.header("X-Radiko-KeyLength")?.toInt() ?: return null
return Auth1Result(authToken, keyOffset, keyLength)
}
}
private fun generatePartialKey(offset: Int, length: Int): String {
val keyBytes = authKey.toByteArray()
val partialBytes = keyBytes.copyOfRange(offset, offset + length)
return Base64.encodeToString(partialBytes, Base64.NO_WRAP)
}
private fun performAuth2(authToken: String, partialKey: String): String? {
val request = Request.Builder()
.url("https://radiko.jp/v2/api/auth2")
.header("X-Radiko-AuthToken", authToken)
.header("X-Radiko-PartialKey", partialKey)
.header("X-Radiko-User", "dummy_user")
.header("X-Radiko-Device", "pc")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
// レスポンス形式: "JP13,東京都,tokyo Japan"
return body.split(",").firstOrNull()
}
}
private data class Auth1Result(
val authToken: String,
val keyOffset: Int,
val keyLength: Int
)
}
放送局情報の取得
エリア内で聴ける放送局の一覧を取得する機能を実装します。
import org.json.JSONArray
import org.json.JSONObject
data class Station(
val id: String,
val name: String,
val region: String
)
class RadikoStationRepository {
private val client = OkHttpClient()
suspend fun getStationList(areaId: String): Result<List<Station>> =
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("https://radiko.jp/v3/station/list/$areaId.xml")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("Failed to get stations"))
}
val xmlData = response.body?.string() ?: ""
val stations = parseStationsFromXml(xmlData)
Result.success(stations)
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseStationsFromXml(xml: String): List<Station> {
val stations = mutableListOf<Station>()
// 簡易的なXMLパース(実際のアプリではXML parserを使用推奨)
val stationPattern = """<station>.*?<id>(.*?)</id>.*?<name>(.*?)</name>.*?<region>(.*?)</region>.*?</station>""".toRegex(RegexOption.DOT_MATCHES_ALL)
stationPattern.findAll(xml).forEach { match ->
val id = match.groupValues[1]
val name = match.groupValues[2]
val region = match.groupValues[3]
stations.add(Station(id, name, region))
}
return stations
}
}
音声再生の実装
ExoPlayerを使ってHLSストリームを再生します。
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.datasource.DefaultHttpDataSource
@UnstableApi
class RadikoPlayer(private val context: Context) {
private var exoPlayer: ExoPlayer? = null
private var authToken: String = ""
fun initialize() {
exoPlayer = ExoPlayer.Builder(context).build()
}
fun play(stationId: String, authToken: String) {
this.authToken = authToken
val streamUrl = "https://radiko.jp/v2/api/ts/playlist.m3u8?station_id=$stationId&l=15"
// AuthTokenをヘッダーに含めたDataSourceを作成
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf(
"X-Radiko-AuthToken" to authToken
))
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(streamUrl))
exoPlayer?.apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
}
}
fun stop() {
exoPlayer?.stop()
}
fun release() {
exoPlayer?.release()
exoPlayer = null
}
fun isPlaying(): Boolean {
return exoPlayer?.isPlaying ?: false
}
}
ViewModelの実装
アプリの状態管理とビジネスロジックを担当するViewModelを作成します。
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class RadikoUiState(
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val stations: List<Station> = emptyList(),
val currentStation: Station? = null,
val isPlaying: Boolean = false,
val error: String? = null
)
class RadikoViewModel(
private val authenticator: RadikoAuthenticator = RadikoAuthenticator(),
private val stationRepository: RadikoStationRepository = RadikoStationRepository()
) : ViewModel() {
private val _uiState = MutableStateFlow(RadikoUiState())
val uiState: StateFlow<RadikoUiState> = _uiState.asStateFlow()
private var authToken: String = ""
private var areaId: String = ""
init {
authenticate()
}
private fun authenticate() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
authenticator.authenticate().fold(
onSuccess = { result ->
authToken = result.authToken
areaId = result.areaId
_uiState.value = _uiState.value.copy(
isAuthenticated = true,
isLoading = false
)
loadStations()
},
onFailure = { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "認証に失敗しました: ${error.message}"
)
}
)
}
}
private fun loadStations() {
viewModelScope.launch {
stationRepository.getStationList(areaId).fold(
onSuccess = { stations ->
_uiState.value = _uiState.value.copy(stations = stations)
},
onFailure = { error ->
_uiState.value = _uiState.value.copy(
error = "放送局リストの取得に失敗しました: ${error.message}"
)
}
)
}
}
fun getAuthToken(): String = authToken
}
Jetpack ComposeでUIを実装
最後に、Jetpack Composeで美しいUIを作成します。
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RadikoApp(
viewModel: RadikoViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val radikoPlayer = remember {
RadikoPlayer(context).apply { initialize() }
}
var currentPlayingStation by remember { mutableStateOf<Station?>(null) }
var isPlaying by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
radikoPlayer.release()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("radiko Player") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
uiState.error != null -> {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = uiState.error ?: "",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
}
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
// 現在再生中の情報
if (currentPlayingStation != null) {
NowPlayingCard(
station = currentPlayingStation!!,
isPlaying = isPlaying,
onPlayPause = {
if (isPlaying) {
radikoPlayer.stop()
isPlaying = false
} else {
radikoPlayer.play(
currentPlayingStation!!.id,
viewModel.getAuthToken()
)
isPlaying = true
}
}
)
}
// 放送局リスト
StationList(
stations = uiState.stations,
currentStation = currentPlayingStation,
onStationClick = { station ->
radikoPlayer.stop()
currentPlayingStation = station
radikoPlayer.play(station.id, viewModel.getAuthToken())
isPlaying = true
}
)
}
}
}
}
}
}
@Composable
fun NowPlayingCard(
station: Station,
isPlaying: Boolean,
onPlayPause: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Now Playing",
style = MaterialTheme.typography.labelSmall
)
Text(
text = station.name,
style = MaterialTheme.typography.titleLarge
)
Text(
text = station.region,
style = MaterialTheme.typography.bodyMedium
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Stop" else "Play",
modifier = Modifier.size(48.dp)
)
}
}
}
}
@Composable
fun StationList(
stations: List<Station>,
currentStation: Station?,
onStationClick: (Station) -> Unit
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(stations) { station ->
StationItem(
station = station,
isSelected = station.id == currentStation?.id,
onClick = { onStationClick(station) }
)
}
}
}
@Composable
fun StationItem(
station: Station,
isSelected: Boolean,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = station.region,
style = MaterialTheme.typography.bodySmall
)
}
if (isSelected) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Playing"
)
}
}
}
}
タイムフリー機能の実装
過去1週間分の番組を聴くためのタイムフリー機能も実装できます。
class RadikoTimeFreePlayer(private val context: Context) {
private var exoPlayer: ExoPlayer? = null
fun initialize() {
exoPlayer = ExoPlayer.Builder(context).build()
}
fun playTimeFree(
stationId: String,
startTime: String, // YYYYMMDDHHmmss形式
endTime: String, // YYYYMMDDHHmmss形式
authToken: String
) {
val streamUrl = "https://radiko.jp/v2/api/ts/playlist.m3u8" +
"?station_id=$stationId" +
"&l=15" +
"&ft=$startTime" +
"&to=$endTime"
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf(
"X-Radiko-AuthToken" to authToken
))
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(streamUrl))
exoPlayer?.apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = true
}
}
fun release() {
exoPlayer?.release()
exoPlayer = null
}
}
タイムフリー再生では、ft(from time)とto(to time)パラメータで再生範囲を指定します。時刻はYYYYMMDDHHmmss形式(例:20250115130000)で指定する必要があります。
エラーハンドリングと注意点
radikoアプリを実装する際の重要な注意点をまとめます。
認証エラーへの対応
認証は時間が経つと失効するため、エラーが発生したら再認証を行う仕組みが必要です。HTTP 403エラーが返ってきた場合は、認証トークンが無効になっている可能性が高いです。
ネットワークエラー
ストリーミング再生中にネットワークが切断される可能性があります。ExoPlayerは自動的にリトライしますが、UIでエラー状態を表示することをおすすめします。
位置情報とエリア制限
radikoは位置情報を元にエリア判定を行います。無料版では現在地のエリアの放送局しか聴けません。プレミアム会員になると全国の放送局が聴けるようになります。
利用規約の遵守
radikoのAPIは非公式であり、利用規約に違反する可能性があります。個人利用に留め、商用利用や大量アクセスは避けましょう。また、録音データの再配布は著作権法違反となります。
パフォーマンス最適化
アプリのパフォーマンスを向上させるためのテクニックを紹介します。
認証結果のキャッシュ
認証処理は毎回行う必要はありません。SharedPreferencesやDataStoreに保存して再利用しましょう。
class AuthCache(private val context: Context) {
private val prefs = context.getSharedPreferences("radiko_auth", Context.MODE_PRIVATE)
fun saveAuth(authToken: String, areaId: String, timestamp: Long) {
prefs.edit().apply {
putString("auth_token", authToken)
putString("area_id", areaId)
putLong("timestamp", timestamp)
apply()
}
}
fun getAuth(): AuthResult? {
val authToken = prefs.getString("auth_token", null) ?: return null
val areaId = prefs.getString("area_id", null) ?: return null
val timestamp = prefs.getLong("timestamp", 0)
// 1時間以内なら有効とみなす
if (System.currentTimeMillis() - timestamp > 3600_000) {
return null
}
return AuthResult(authToken, areaId)
}
}
放送局リストのキャッシュ
放送局リストは頻繁に変わらないため、一度取得したらキャッシュしておくと良いでしょう。
まとめ
radikoのAPIを使ってAndroidアプリを作る方法を解説しました。重要なポイントをおさらいしましょう。
radikoの認証はAuth1とAuth2の2段階で行います。HLS形式のストリーミングを再生するため、ExoPlayerが便利です。Jetpack Composeを使うことで、モダンで保守性の高いUIが実装できます。タイムフリー機能を使えば、過去1週間分の番組も聴けます。エラーハンドリングと認証キャッシュで安定したアプリが作れます。
radikoのAPIは非公式なため、仕様が変更される可能性があります。また、利用規約を遵守し、個人利用の範囲で楽しみましょう。
この記事を参考に、ぜひあなたもオリジナルのラジオプレーヤーアプリを作ってみてください。
参考リンク
- radiko公式サイト
- ExoPlayer公式ドキュメント
- Jetpack Compose公式ガイド
- 前回の記事:radiko APIを使用してラジオを聞いてみる
この記事が役に立ちましたら、ぜひシェアしてください。質問やコメントもお待ちしています!
ディスカッション
コメント一覧
まだ、コメントがありません