ショウのプログラミング教室

【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は非公式なため、仕様が変更される可能性があります。また、利用規約を遵守し、個人利用の範囲で楽しみましょう。

この記事を参考に、ぜひあなたもオリジナルのラジオプレーヤーアプリを作ってみてください。

参考リンク


この記事が役に立ちましたら、ぜひシェアしてください。質問やコメントもお待ちしています!

モバイルバージョンを終了