Okay, this is an excellent request that combines UI replication with a challenging data integration task from a Blogger blog. As an expert software developer and image analysis expert, I've thoroughly reviewed your provided screenshots and understood your requirements:
- Design Replication: Mimic the main list, mini-player, full-screen player, and favorite screen layouts.
- Blogger Integration: Automatically add new radio stations to the app when they are created on your blog (
https://seebatorefmtamil.blogspot.com
).
This requires a robust Android application built with best practices, including background media playback, data persistence, and a clear architecture.
I will provide a comprehensive solution using Kotlin, XML layouts, Jetpack Navigation Component, ExoPlayer for media, Room for local database (favorites), and Glide for image loading.
Understanding Blogger Integration Strategy
The most critical and challenging part is fetching radio station data from your Blogger blog and ensuring it "automatically adds" to the app. Blogger is primarily a blogging platform, not a structured database for radio stations. To make this work reliably, we need a clear convention for how you structure your blog posts:
Proposed Blogger Post Convention for Radio Stations:
For each radio station you want in your app, create a new blog post on https://seebatorefmtamil.blogspot.com
following these guidelines:
- Post Title: This will be the Radio Station Name displayed in the app (e.g., "Smart FM Indonesia").
- Labels (Categories): Use Blogger's built-in "Labels" feature. Add one or more labels (e.g., "Culture", "World", "Misc"). The app will use the first label as the station's category.
- Post Content (HTML Body):
- Stream URL: Include the direct streaming URL for the radio station somewhere in the post body. The app will attempt to find the first URL in the post content that ends with a common audio extension (e.g.,
.mp3
,.aac
,.m3u8
,.pls
,.ogg
,.wav
).- Example in post body:
Listen live at <a href="http://stream.example.com/radio.mp3">this link</a>.
or simplyThe stream is: http://stream.example.com/radio.mp3
- Example in post body:
- Image URL (Station Logo): Include an image for the station logo within the post body. The app will extract the
src
attribute of the first<img>
tag found in the post content as the station's icon.- Example in post body:
<img src="http://image.example.com/logo.png" alt="Station Logo">
- Example in post body:
- Stream URL: Include the direct streaming URL for the radio station somewhere in the post body. The app will attempt to find the first URL in the post content that ends with a common audio extension (e.g.,
Why this approach?
- It leverages standard Blogger features (posts, titles, labels).
- It avoids complex custom fields or APIs not readily available for Blogger.
- The app will parse the RSS feed's HTML content heuristically to find the stream and image URLs. While not foolproof, it's the most practical balance between ease of use for you and implementability in the app.
Application Architecture Overview
- Data Layer:
RadioStation
data class: Represents a radio station with its properties.RadioStationRepository
: Fetches and parses the Blogger RSS feed. Also manages favorite stations via Room.RadioStationDao
&RadioStationDatabase
: For local persistence of favorite stations using Room.
- Service Layer:
PlaybackService
(MediaBrowserServiceCompat
): Manages ExoPlayer, handles background playback, media session callbacks, and builds the persistent media notification.
- Presentation Layer (UI - Fragments & ViewModel):
MainActivity
: Hosts theNavHostFragment
for navigation and the persistent mini-player.SharedViewModel
: A ViewModel scoped toMainActivity
to share playback state and controlPlaybackService
across different fragments (Home, Player, Favorites).HomeFragment
: Displays the list of all radio stations fetched from Blogger.PlayerFragment
: The full-screen radio player with detailed controls.FavoriteFragment
: Displays the list of favorite radio stations.CategoryFragment
: A placeholder as per the UI, which can be expanded later.
Step-by-Step Code Generation
Due to the extensive nature of this request, I will provide the code in logical sections:
build.gradle
(app-level) & Permissions- Data Model (
RadioStation
,FavoriteRadioStation
) - Data Source (
BloggerRssParser
,RadioStationRepository
) - Local Database (Room:
RadioStationDao
,RadioStationDatabase
) - Media Playback Service (
PlaybackService
) SharedViewModel
(for UI-Service communication)- UI Layouts (
activity_main.xml
,layout_mini_player.xml
,fragment_home.xml
,list_item_radio_station.xml
,fragment_player.xml
,fragment_favorite.xml
,fragment_category.xml
) - UI Fragments (
HomeFragment
,PlayerFragment
,FavoriteFragment
,CategoryFragment
) MainActivity
AndroidManifest.xml
- Helper Classes & Resources
---### 1. build.gradle
(app-level) & Permissions
First, set up your project dependencies.
build.gradle
(app-level)
copygradle
1plugins { 2 id 'com.android.application' 3 id 'org.jetbrains.kotlin.android' 4 id 'kotlin-kapt' // For Room 5 id 'androidx.navigation.safeargs.kotlin' // For Navigation Component arguments 6}android { 7 namespace 'com.example.bloggeradioapp' 8 compileSdk 34 9 10 defaultConfig { 11 applicationId "com.example.bloggeradioapp" 12 minSdk 24 13 targetSdk 34 14 versionCode 1 15 versionName "1.0" 16 17 testInstrumentationRunner "androidx.test.runner.AndroidJUnitAndroidJUnitRunner" 18 } 19 20 buildTypes { 21 release { 22 minifyEnabled false 23 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 } 25 } 26 compileOptions { 27 sourceCompatibility JavaVersion.VERSION_1_8 28 targetCompatibility JavaVersion.VERSION_1_8 29 } 30 kotlinOptions { 31 jvmTarget = '1.8' 32 } 33 buildFeatures { 34 viewBinding true // Enable View Binding 35 } 36} 37 38dependencies { 39 implementation 'androidx.core:core-ktx:1.12.0' 40 implementation 'androidx.appcompat:appcompat:1.6.1' 41 implementation 'com.google.android.material:material:1.11.0' 42 implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 43 implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' 44 implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' 45 implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' 46 implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' 47 implementation 'androidx.fragment:fragment-ktx:1.6.2' // For viewModels() delegate 48 49 // ExoPlayer for media playback 50 implementation 'com.google.android.exoplayer:exoplayer:2.19.1' 51 implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' 52 53 // Glide for image loading 54 implementation 'com.github.bumptech.glide:glide:4.16.0' 55 kapt 'com.github.bumptech.glide:compiler:4.16.0' 56 57 // Room for local database (Favorites) 58 implementation 'androidx.room:room-runtime:2.6.1' 59 kapt 'androidx.room:room-compiler:2.6.1' 60 implementation 'androidx.room:room-ktx:2.6.1' // Kotlin Extensions and Coroutines support 61 62 // Coroutines for asynchronous operations 63 implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' 64 implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' 65 66 // For network requests (fetching RSS) 67 implementation 'com.squareup.okhttp3:okhttp:4.12.0' 68 69 // Testing (optional, but good practice) 70 testImplementation 'junit:junit:4.13.2' 71 androidTestImplementation 'androidx.test.ext:junit:1.1.5' 72 androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 73}
copy
AndroidManifest.xml
(Initial Permissions)
You'll need internet access for streaming and fetching blog data.
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:tools="http://schemas.android.com/tools"> 4 5 <uses-permission android:name="android.permission.INTERNET" /> 6 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 7 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- For Android 13+ notifications --> 8 9 <application 10 android:allowBackup="true" 11 android:dataExtractionRules="@xml/data_extraction_rules" 12 android:fullBackupContent="@xml/backup_rules" 13 android:icon="@mipmap/ic_launcher" 14 android:label="@string/app_name" 15 android:roundIcon="@mipmap/ic_launcher_round" 16 android:supportsRtl="true" 17 android:theme="@style/Theme.BloggerRadioApp" 18 android:usesCleartextTraffic="true" tools:targetApi="31"> <!-- Use usesCleartextTraffic="true" if your streams are HTTP, otherwise remove --> 19 20 <activity 21 android:name=".MainActivity" 22 android:exported="true"> 23 <intent-filter> 24 <action android:name="android.intent.action.MAIN" /> 25 <category android:name="android.intent.category.LAUNCHER" /> 26 </intent-filter> 27 </activity> 28 29 <service 30 android:name=".playback.PlaybackService" 31 android:exported="true" 32 android:foregroundServiceType="mediaPlayback"> 33 <intent-filter> 34 <action android:name="android.media.browse.MediaBrowserService" /> 35 </intent-filter> 36 </service> 37 38 </application> 39 40</manifest>
copy
usesCleartextTraffic="true"
: Only include this if your stream URLs or Blogger RSS feed arehttp://
(nothttps://
). It's recommended to usehttps://
for security.POST_NOTIFICATIONS
: Essential for Android 13+ to show foreground service notifications.
2. Data Model
Create Kotlin data classes for your radio stations.
app/src/main/java/com/example/bloggeradioapp/data/model/RadioStation.kt
copykotlin
1package com.example.bloggeradioapp.data.model 2 3import android.os.Parcelable 4import androidx.room.Entity 5import androidx.room.PrimaryKey 6import kotlinx.parcelize.Parcelize 7 8// Represents a radio station fetched from Blogger or stored as a favorite 9@Parcelize 10data class RadioStation( 11 val id: String, // Unique ID, e.g., Blogger post ID or a generated UUID 12 val name: String, 13 val streamUrl: String, 14 val imageUrl: String?, // Optional image URL 15 val category: String?, // Category from Blogger labels 16 val description: String?, // Can be part of post content 17 var isFavorite: Boolean = false // Track if it's a favorite 18) : Parcelable 19 20// Represents a favorite radio station stored in Room 21@Entity(tableName = "favorite_radio_stations") 22data class FavoriteRadioStation( 23 @PrimaryKey val id: String, // Matches RadioStation.id 24 val name: String, 25 val streamUrl: String, 26 val imageUrl: String?, 27 val category: String?, 28 val description: String? 29) { 30 // Convert to RadioStation for consistent use in UI 31 fun toRadioStation(): RadioStation { 32 return RadioStation( 33 id = id, 34 name = name, 35 streamUrl = streamUrl, 36 imageUrl = imageUrl, 37 category = category, 38 description = description, 39 isFavorite = true 40 ) } 41}
copy
3. Data Source (BloggerRssParser
, RadioStationRepository
)
This is where the magic of fetching from Blogger happens. We'll use OkHttpClient
to get the RSS feed and XmlPullParser
to parse it.
app/src/main/java/com/example/bloggeradioapp/data/source/BloggerRssParser.kt
copykotlin
1package com.example.bloggeradioapp.data.source 2 3import android.util.Log 4import android.util.Xml 5import com.example.bloggeradioapp.data.model.RadioStation 6import okhttp3.OkHttpClient 7import okhttp3.Request 8import org.xmlpull.v1.XmlPullParser 9import org.xmlpull.v1.XmlPullParserException 10import java.io.IOException 11import java.io.InputStream 12import java.io.StringReader 13import java.util.UUID 14import java.util.regex.Pattern 15 16/** 17 * Parses the Blogger RSS feed to extract RadioStation data. 18 * Assumes a specific convention for blog posts: 19 * - Post Title: Radio Station Name 20 * - Post Labels: Radio Station Category 21 * - Post Content: Contains stream URL (first URL ending in audio extensions) 22 * and image URL (src of first <img> tag). 23 */ 24class BloggerRssParser(private val client: OkHttpClient) { 25 26 private val ns: String? = null // No namespace for RSS 27 28 // Regex to find common audio stream URLs 29 private val audioUrlPattern = Pattern.compile( 30 "(https?://[^\\s\"']+\\.(mp3|aac|m3u8|pls|ogg|wav|flac|m4a))", 31 Pattern.CASE_INSENSITIVE 32 ) 33 // Regex to find image URLs within HTML content 34 private val imageUrlPattern = Pattern.compile( 35 "<img[^>]+src\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>", 36 Pattern.CASE_INSENSITIVE 37 ) 38 // Regex to find any http/https URL 39 private val anyUrlPattern = Pattern.compile( 40 "https?://[^\\s\"']+", 41 Pattern.CASE_INSENSITIVE 42 ) 43 44 suspend fun parseRssFeed(feedUrl: String): List<RadioStation> { 45 val request = Request.Builder().url(feedUrl).build() 46 return try { 47 val response = client.newCall(request).execute() 48 if (response.isSuccessful) { 49 response.body?.byteStream()?.use { inputStream -> 50 parse(inputStream) 51 } ?: emptyList() 52 } else { 53 Log.e("BloggerRssParser", "Failed to fetch RSS: ${response.code} ${response.message}") 54 emptyList() } 55 } catch (e: IOException) { 56 Log.e("BloggerRssParser", "Network error fetching RSS", e) 57 emptyList() 58 } catch (e: XmlPullParserException) { 59 Log.e("BloggerRssParser", "XML parsing error", e) 60 emptyList() 61 } 62 } 63 64 @Throws(XmlPullParserException::class, IOException::class) 65 private fun parse(inputStream: InputStream): List<RadioStation> { 66 inputStream.use { stream -> 67 val parser: XmlPullParser = Xml.newPullParser() 68 parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) 69 parser.setInput(stream, null) 70 parser.nextTag() 71 return readFeed(parser) 72 } 73 } 74 75 @Throws(XmlPullParserException::class, IOException::class) 76 private fun readFeed(parser: XmlPullParser): List<RadioStation> { 77 val entries = mutableListOf<RadioStation>() 78 parser.require(XmlPullParser.START_TAG, ns, "feed") // For Atom feed (Blogger uses Atom, not RSS 2.0 directly) 79 80 while (parser.next() != XmlPullParser.END_TAG) { 81 if (parser.eventType != XmlPullParser.START_TAG) { 82 continue 83 } 84 if (parser.name == "entry") { 85 entries.add(readEntry(parser)) 86 } else { 87 skip(parser) 88 } 89 } 90 return entries 91 } 92 93 @Throws(XmlPullParserException::class, IOException::class) 94 private fun readEntry(parser: XmlPullParser): RadioStation { 95 parser.require(XmlPullParser.START_TAG, ns, "entry") 96 var id: String? = null // Blogger Post ID 97 var title: String? = null 98 var content: String? = null 99 var category: String? = null // First label will be the category 100 101 while (parser.next() != XmlPullParser.END_TAG) { 102 if (parser.eventType != XmlPullParser.START_TAG) { 103 continue 104 } 105 when (parser.name) { 106 "id" -> id = readText(parser, "id") 107 "title" -> title = readText(parser, "title") 108 "content" -> content = readText(parser, "content") 109 "category" -> { 110 if (category == null) { // Take the first category as the main one 111 category = parser.getAttributeValue(null, "term") 112 } 113 skip(parser) // Skip the category tag after reading attribute 114 } 115 else -> skip(parser) 116 } 117 } 118 119 // Extract stream URL and image URL from content 120 val streamUrl = content?.let { extractStreamUrl(it) } 121 val imageUrl = content?.let { extractImageUrl(it) } 122 123 if (id == null) { 124 // If Blogger's ID is not available for some reason, generate one. 125 // This is crucial for Room PrimaryKey. 126 id = UUID.randomUUID().toString() 127 Log.w("BloggerRssParser", "Blogger post ID missing, generated UUID: $id for title: $title") 128 } 129 130 return RadioStation( 131 id = id, 132 name = title ?: "Untitled Radio", 133 streamUrl = streamUrl ?: "", // Fallback to empty string, though app should handle this 134 imageUrl = imageUrl, 135 category = category ?: "Misc", 136 description = null // We are not extracting detailed description for now 137 ).also { 138 if (it.streamUrl.isEmpty()) { 139 Log.w("BloggerRssParser", "No stream URL found for station: ${it.name}") 140 } 141 } 142 } 143 144 private fun extractStreamUrl(htmlContent: String): String? { 145 val matcher = audioUrlPattern.matcher(htmlContent) 146 if (matcher.find()) { 147 return matcher.group(1) 148 } 149 // Fallback: if no specific audio URL, try to find any general URL 150 val anyUrlMatcher = anyUrlPattern.matcher(htmlContent) 151 if (anyUrlMatcher.find()) { 152 Log.w("BloggerRssParser", "No specific audio URL found, using generic URL as stream: ${anyUrlMatcher.group(0)}") 153 return anyUrlMatcher.group(0) 154 } 155 return null 156 } 157 158 private fun extractImageUrl(htmlContent: String): String? { 159 val matcher = imageUrlPattern.matcher(htmlContent) 160 if (matcher.find()) { 161 return matcher.group(1) 162 } 163 return null 164 } 165 166 @Throws(XmlPullParserException::class, IOException::class) 167 private fun readText(parser: XmlPullParser, tag: String): String { 168 parser.require(XmlPullParser.START_TAG, ns, tag) 169 val result = if (parser.next() == XmlPullParser.TEXT) { 170 parser.text 171 } else { 172 "" 173 } 174 parser.nextTag() 175 parser.require(XmlPullParser.END_TAG, ns, tag) 176 return result 177 } 178 179 @Throws(XmlPullParserException::class, IOException::class) 180 private fun skip(parser: XmlPullParser) { 181 if (parser.eventType != XmlPullParser.START_TAG) { 182 throw IllegalStateException() 183 } 184 var depth = 1 185 while (depth != 0) { 186 when (parser.next()) { 187 XmlPullParser.END_TAG -> depth-- 188 XmlPullParser.START_TAG -> depth++ 189 } 190 } 191 } 192}
copy
app/src/main/java/com/example/bloggeradioapp/data/source/RadioStationRepository.kt
copykotlin
1package com.example.bloggeradioapp.data.source 2 3import com.example.bloggeradioapp.data.model.FavoriteRadioStation 4import com.example.bloggeradioapp.data.model.RadioStation 5import com.example.bloggeradioapp.data.local.RadioStationDao 6import kotlinx.coroutines.flow.Flow 7import kotlinx.coroutines.flow.combine 8import kotlinx.coroutines.flow.map 9 10/** 11 * Repository for managing radio station data, combining data from Blogger RSS and local favorites. 12 */ 13class RadioStationRepository( 14 private val bloggerRssParser: BloggerRssParser, 15 private val radioStationDao: RadioStationDao, 16 private val bloggerFeedUrl: String // Inject the blog feed URL 17) { 18 19 // Fetch radio stations from Blogger RSS and combine with favorite status from DB 20 fun getRadioStations(): Flow<List<RadioStation>> { 21 val bloggerStationsFlow = kotlinx.coroutines.flow.flow { 22 emit(bloggerRssParser.parseRssFeed(bloggerFeedUrl)) 23 } 24 25 val favoriteStationsFlow = radioStationDao.getAllFavoriteStations() 26 27 // Combine the two flows: mark stations from Blogger as favorite if they exist in the DB 28 return combine(bloggerStationsFlow, favoriteStationsFlow) { bloggerStations, favoriteStations -> 29 val favoriteStationIds = favoriteStations.map { it.id }.toSet() 30 bloggerStations.map { station -> 31 station.copy(isFavorite = favoriteStationIds.contains(station.id)) 32 } 33 } 34 } 35 36 // Get only favorite stations 37 fun getFavoriteStations(): Flow<List<RadioStation>> { 38 return radioStationDao.getAllFavoriteStations().map { favoriteList -> 39 favoriteList.map { it.toRadioStation() } 40 } 41 } 42 43 suspend fun addFavorite(station: RadioStation) { 44 radioStationDao.insertFavoriteStation( 45 FavoriteRadioStation( 46 id = station.id, 47 name = station.name, 48 streamUrl = station.streamUrl, 49 imageUrl = station.imageUrl, 50 category = station.category, 51 description = station.description 52 ) 53 ) 54 } 55 56 suspend fun removeFavorite(stationId: String) { 57 radioStationDao.deleteFavoriteStationById(stationId) 58 } 59 60 suspend fun isFavorite(stationId: String): Boolean { 61 return radioStationDao.getFavoriteStationById(stationId) != null 62 } 63}
copy
4. Local Database (Room)
For storing favorite radio stations.
app/src/main/java/com/example/bloggeradioapp/data/local/RadioStationDao.kt
copykotlin
1package com.example.bloggeradioapp.data.local 2 3import androidx.room.Dao 4import androidx.room.Insertimport androidx.room.OnConflictStrategy 5import androidx.room.Query 6import com.example.bloggeradioapp.data.model.FavoriteRadioStation 7import kotlinx.coroutines.flow.Flow 8 9@Dao 10interface RadioStationDao { 11 @Insert(onConflict = OnConflictStrategy.REPLACE) 12 suspend fun insertFavoriteStation(station: FavoriteRadioStation) 13 14 @Query("DELETE FROM favorite_radio_stations WHERE id = :stationId") 15 suspend fun deleteFavoriteStationById(stationId: String) 16 17 @Query("SELECT * FROM favorite_radio_stations") 18 fun getAllFavoriteStations(): Flow<List<FavoriteRadioStation>> 19 20 @Query("SELECT * FROM favorite_radio_stations WHERE id = :stationId") 21 suspend fun getFavoriteStationById(stationId: String): FavoriteRadioStation? 22}
copy
app/src/main/java/com/example/bloggeradioapp/data/local/RadioStationDatabase.kt
copykotlin
1package com.example.bloggeradioapp.data.local 2 3import android.content.Context 4import androidx.room.Database 5import androidx.room.Room 6import androidx.room.RoomDatabase 7import com.example.bloggeradioapp.data.model.FavoriteRadioStation 8 9@Database(entities = [FavoriteRadioStation::class], version = 1, exportSchema = false) 10abstract class RadioStationDatabase : RoomDatabase() { 11 abstract fun radioStationDao(): RadioStationDao 12 13 companion object { 14 @Volatile 15 private var INSTANCE: RadioStationDatabase? = null 16 17 fun getDatabase(context: Context): RadioStationDatabase { 18 return INSTANCE ?: synchronized(this) { 19 val instance = Room.databaseBuilder( 20 context.applicationContext, 21 RadioStationDatabase::class.java, 22 "radio_station_db" 23 ).build() 24 INSTANCE = instance 25 instance 26 } 27 } 28 } 29}
copy
5. Media Playback Service (PlaybackService
)
This service handles the actual audio playback, background operations, and media notifications.
app/src/main/java/com/example/bloggeradioapp/playback/PlaybackService.kt
copykotlin
1package com.example.bloggeradioapp.playback 2 3import android.app.NotificationChannel 4import android.app.NotificationManager 5import android.app.PendingIntent 6import android.content.Context 7import android.content.Intent 8import android.graphics.Bitmap 9import android.graphics.drawable.Drawable 10import android.net.Uri 11import android.os.Build 12import android.os.Bundle 13import android.support.v4.media.MediaBrowserCompat 14import android.support.v4.media.MediaDescriptionCompat 15import android.support.v4.media.MediaMetadataCompat 16import android.support.v4.media.session.MediaSessionCompat 17import android.support.v4.media.session.PlaybackStateCompat 18import android.util.Log 19import androidx.annotation.OptIn 20import androidx.core.app.NotificationCompat 21import androidx.media.MediaBrowserServiceCompat 22import androidx.media3.common.AudioAttributes 23import androidx.media3.common.C 24import androidx.media3.common.MediaItem 25import androidx.media3.common.Player 26import androidx.media3.exoplayer.ExoPlayer 27import androidx.media3.exoplayer.trackselection.DefaultTrackSelector 28import androidx.media3.session.MediaSession 29import androidx.media3.ui.PlayerNotificationManager 30import androidx.media3.ui.PlayerNotificationManager.NotificationListener 31import com.bumptech.glide.Glide 32import com.bumptech.glide.request.target.CustomTarget 33import com.bumptech.glide.request.transition.Transition 34import com.example.bloggeradioapp.MainActivity 35import com.example.bloggeradioapp.R 36import com.example.bloggeradioapp.data.model.RadioStation 37import kotlinx.coroutines.CoroutineScope 38import kotlinx.coroutines.Dispatchers 39import kotlinx.coroutines.SupervisorJob 40import kotlinx.coroutines.launch 41import kotlinx.coroutines.withContext 42 43class PlaybackService : MediaBrowserServiceCompat() { 44 45 private lateinit var mediaSession: MediaSessionCompat 46 private lateinit var exoPlayer: ExoPlayer 47 private lateinit var notificationManager: NotificationManager 48 private lateinit var playerNotificationManager: PlayerNotificationManager 49 50 private val serviceJob = SupervisorJob() 51 private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) 52 53 private var currentStation: RadioStation? = null 54 private var isForegroundService = false 55 56 companion object { 57 const val CHANNEL_ID = "BloggerRadioChannel" 58 const val NOTIFICATION_ID = 101 59 const val ROOT_ID = "root_id" 60 } 61 62 override fun onCreate() { 63 super.onCreate() 64 65 // Create a MediaSessionCompat 66 mediaSession = MediaSessionCompat(baseContext, "BloggerRadioApp") 67 .apply { 68 setFlags( 69 MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or 70 MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS 71 ) 72 setPlaybackState( 73 PlaybackStateCompat.Builder() 74 .setState(PlaybackStateCompat.STATE_STOPPED, 0, 1.0f) 75 .setActions( 76 PlaybackStateCompat.ACTION_PLAY or 77 PlaybackStateCompat.ACTION_PAUSE or 78 PlaybackStateCompat.ACTION_PLAY_PAUSE or 79 PlaybackStateCompat.ACTION_STOP 80 ) 81 .build() 82 ) 83 setCallback(mediaSessionCallback) 84 setSessionToken(sessionToken) 85 } 86 87 // Configure ExoPlayer 88 val audioAttributes = AudioAttributes.Builder() 89 .setUsage(C.USAGE_MEDIA) 90 .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) // Or C.AUDIO_CONTENT_TYPE_MUSIC 91 .build() 92 93 exoPlayer = ExoPlayer.Builder(applicationContext) 94 .setTrackSelector(DefaultTrackSelector(applicationContext)) 95 .setAudioAttributes(audioAttributes, true) // Handle audio focus 96 .build().apply { 97 addListener(playerListener) 98 } 99 100 notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 101 createNotificationChannel() 102 103 playerNotificationManager = PlayerNotificationManager.Builder( 104 this, 105 NOTIFICATION_ID, 106 CHANNEL_ID 107 ) 108 .setMediaDescriptionAdapter(DescriptionAdapter(mediaSession.sessionToken)) 109 .setNotificationListener(PlayerNotificationListener()) 110 .build().apply { 111 setPlayer(exoPlayer) 112 setMediaSessionToken(mediaSession.sessionToken) 113 setUse 114 setUse 115 setPriority(NotificationCompat.PRIORITY_LOW) // Or PRIORITY_HIGH if preferred 116 setSmallIcon(R.drawable.ic_radio) // Use a suitable icon 117 } 118 119 Log.d("PlaybackService", "Service created") 120 } 121 122 @OptIn(androidx.media3.common.util.UnstableApi::class) 123 private fun createNotificationChannel() { 124 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 125 val channel = NotificationChannel( 126 CHANNEL_ID, 127 getString(R.string.notification_channel_name), 128 NotificationManager.IMPORTANCE_LOW // Low importance to hide time, High if you want it 129 ).apply { 130 description = getString(R.string.notification_channel_description) 131 } 132 notificationManager.createNotificationChannel(channel) 133 } 134 } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 135 Log.d("PlaybackService", "onStartCommand received") 136 // The service is started by MediaBrowserCompat.connect(). 137 // If it's killed by the system, we want it to restart and resume playback. 138 return START_NOT_STICKY // Or START_STICKY if you want it to restart with previous intent 139 } 140 141 override fun onGetRoot( 142 clientPackageName: String, 143 clientUid: Int, 144 rootHints: Bundle? 145 ): BrowserRoot { 146 return BrowserRoot(ROOT_ID, null) 147 } 148 149 override fun onLoadChildren( 150 parentId: String, 151 result: Result<MutableList<MediaBrowserCompat.MediaItem>> 152 ) { 153 // We don't provide a browseable media hierarchy in this simple radio app 154 result.sendResult(null) 155 } 156 157 private val mediaSessionCallback = object : MediaSessionCompat.Callback() { 158 override fun onPlay() { 159 Log.d("PlaybackService", "MediaSession: onPlay") 160 if (exoPlayer.playbackState == Player.STATE_IDLE || exoPlayer.playbackState == Player.STATE_ENDED) { 161 // If there's a current station, attempt to play it again 162 currentStation?.let { playStation(it) } 163 } else if (exoPlayer.playbackState == Player.STATE_PAUSED) { 164 exoPlayer.play() 165 } 166 updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) 167 } 168 169 override fun onPause() { 170 Log.d("PlaybackService", "MediaSession: onPause") 171 exoPlayer.pause() 172 updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) 173 } 174 175 override fun onStop() { 176 Log.d("PlaybackService", "MediaSession: onStop") 177 stopPlaybackAndRelease() 178 updatePlaybackState(PlaybackStateCompat.STATE_STOPPED) 179 stopSelf() // Stop the service when playback is stopped 180 } 181 182 override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { 183 Log.d("PlaybackService", "MediaSession: onPlayFromMediaId: $mediaId") 184 extras?.getParcelable<RadioStation>("radio_station")?.let { station -> 185 playStation(station) 186 } 187 } 188 } 189 190 private val playerListener = object : Player.Listener { 191 override fun onPlaybackStateChanged(playbackState: Int) { 192 super.onPlaybackStateChanged(playbackState) 193 val state = when (playbackState) { 194 Player.STATE_BUFFERING -> PlaybackStateCompat.STATE_BUFFERING 195 Player.STATE_READY -> if (exoPlayer.playWhenReady) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED 196 Player.STATE_ENDED -> PlaybackStateCompat.STATE_STOPPED 197 Player.STATE_IDLE -> PlaybackStateCompat.STATE_STOPPED 198 else -> PlaybackStateCompat.STATE_NONE 199 } 200 updatePlaybackState(state) 201 } 202 203 override fun onIsPlayingChanged(isPlaying: Boolean) { 204 super.onIsPlayingChanged(isPlaying) 205 val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED 206 updatePlaybackState(state) 207 } 208 } 209 210 private fun updatePlaybackState(state: Int) { 211 val actions = if (state == PlaybackStateCompat.STATE_PLAYING) { 212 PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_STOP 213 } else { 214 PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_STOP 215 } 216 217 val playbackState = PlaybackStateCompat.Builder() 218 .setState(state, exoPlayer.currentPosition, 1.0f) 219 .setActions(actions) 220 .build() 221 mediaSession.setPlaybackState(playbackState) 222 } 223 224 fun playStation(station: RadioStation) { 225 if (currentStation == station && exoPlayer.isPlaying) { 226 Log.d("PlaybackService", "Station already playing: ${station.name}") 227 return // Station is already playing 228 } 229 230 Log.d("PlaybackService", "Playing station: ${station.name} from ${station.streamUrl}") 231 currentStation = station 232 val mediaItem = MediaItem.fromUri(station.streamUrl) 233 exoPlayer.setMediaItem(mediaItem) 234 exoPlayer.prepare() 235 exoPlayer.play() 236 237 // Update MediaSession metadata 238 val metadataBuilder = MediaMetadataCompat.Builder() 239 .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, station.id) 240 .putString(MediaMetadataCompat.METADATA_KEY_TITLE, station.name) 241 .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, station.category ?: "Radio") 242 .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, station.name) 243 .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, station.category ?: "Radio") 244 .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, station.imageUrl) 245 .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, C.TIME_UNSET) // For live streams serviceScope.launch { 246 val bitmap = station.imageUrl?.let { url -> 247 try { 248 withContext(Dispatchers.IO) { 249 Glide.with(applicationContext) 250 .asBitmap() 251 .load(url) 252 .submit(512, 512) // Request a reasonable size 253 .get() 254 } 255 } catch (e: Exception) { 256 Log.e("PlaybackService", "Error loading image for notification: $url", e) 257 null 258 } 259 } ?: withContext(Dispatchers.IO) { 260 // Fallback to a default icon if no image URL or failed to load 261 Glide.with(applicationContext) 262 .asBitmap() 263 .load(R.drawable.ic_radio_default_art) // Make sure you have a default icon 264 .submit(512, 512) 265 .get() 266 } 267 bitmap?.let { metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it) } 268 mediaSession.setMetadata(metadataBuilder.build()) 269 } 270 } 271 272 fun getCurrentPlayingStation(): RadioStation? { 273 return currentStation 274 } 275 276 fun getPlaybackState(): Int { 277 return exoPlayer.playbackState 278 } 279 280 fun isPlaying(): Boolean { 281 return exoPlayer.isPlaying 282 } 283 284 fun pausePlayback() { 285 exoPlayer.pause() 286 updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) 287 } 288 289 fun resumePlayback() { 290 exoPlayer.play() 291 updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) 292 } 293 294 private fun stopPlaybackAndRelease() { 295 Log.d("PlaybackService", "Stopping playback and releasing player") 296 exoPlayer.stop() 297 exoPlayer.release() 298 mediaSession.isActive = false // Deactivate media session 299 playerNotificationManager.setPlayer(null) // Detach notification manager 300 notificationManager.cancel(NOTIFICATION_ID) // Explicitly cancel notification 301 currentStation = null 302 } 303 304 override fun onDestroy() { 305 Log.d("PlaybackService", "Service onDestroy") 306 stopPlaybackAndRelease() 307 mediaSession.release() 308 serviceJob.cancel() // Cancel all coroutines 309 super.onDestroy() 310 } 311 312 // --- PlayerNotificationManager.MediaDescriptionAdapter --- 313 private inner class DescriptionAdapter(private val sessionToken: MediaSessionCompat.Token) : 314 PlayerNotificationManager.MediaDescriptionAdapter { 315 316 override fun getCurrentContentTitle(player: Player): CharSequence { 317 return currentStation?.name ?: "Unknown Radio" 318 } override fun createContentIntent(player: Player): PendingIntent? { 319 val intent = Intent(applicationContext, MainActivity::class.java).apply { 320 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP 321 } 322 return PendingIntent.getActivity( 323 applicationContext, 324 0, 325 intent, 326 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT 327 ) 328 } override fun getCurrentContentText(player: Player): CharSequence? { 329 return currentStation?.category ?: "Live Stream" 330 } 331 332 override fun getCurrentLargeIcon( 333 player: Player, 334 callback: PlayerNotificationManager.BitmapCallback 335 ): Bitmap? { 336 currentStation?.imageUrl?.let { url -> 337 Glide.with(applicationContext) 338 .asBitmap() 339 .load(url) 340 .into(object : CustomTarget<Bitmap>() { 341 override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { 342 callback.onBitmap(resource) 343 } 344 345 override fun onLoadCleared(placeholder: Drawable?) { 346 // Do nothing 347 } 348 349 override fun onLoadFailed(errorDrawable: Drawable?) { 350 // Load default icon on failure 351 Glide.with(applicationContext) 352 .asBitmap() 353 .load(R.drawable.ic_radio_default_art) 354 .into(object : CustomTarget<Bitmap>() { 355 override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { 356 callback.onBitmap(resource) 357 } 358 override fun onLoadCleared(placeholder: Drawable?) {} 359 }) 360 } 361 }) 362 } 363 return null // Return null initially, callback will provide the bitmap 364 } } 365 366 // --- PlayerNotificationManager.NotificationListener --- 367 private inner class PlayerNotificationListener : NotificationListener { 368 override fun onNotificationPosted(notificationId: Int, notification: android.app.Notification, ongoing: Boolean) { 369 if (ongoing && !isForegroundService) { 370 startForeground(notificationId, notification) 371 isForegroundService = true 372 } 373 } 374 375 override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { 376 if (isForegroundService) { 377 stopForeground(true) 378 isForegroundService = false 379 stopSelf() // Stop the service if notification is cancelled 380 } 381 } 382 } 383}
copy
Important: You need to add a default radio icon ic_radio_default_art.png
to your drawable
folder. For example, a simple microphone icon. Also ic_radio.xml
for small icon.
app/src/main/res/drawable/ic_radio.xml
(or similar, for small notification icon)
copyxml
1<vector xmlns:android="http://schemas.android.com/apk/res/android" 2 android:width="24dp" 3 android:height="24dp" 4 android:viewportWidth="24" 5 android:viewportHeight="24"> 6 <path 7 android:fillColor="@android:color/white" 8 android:pathData="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,0 15,12A3,3 0 0,0 12,15A3,3 0 0,0 9,12A3,3 0 0,0 12,9Z" /> 9</vector>
6. SharedViewModel
This ViewModel will be used by MainActivity
and all fragments to communicate with the PlaybackService
and share playback state.
app/src/main/java/com/example/bloggeradioapp/ui/SharedViewModel.kt
copykotlin
1package com.example.bloggeradioapp.ui 2 3import android.content.ComponentName 4import android.content.Context 5import android.os.Bundle 6import android.support.v4.media.MediaBrowserCompat 7import android.support.v4.media.MediaMetadataCompat 8import android.support.v4.media.session.MediaControllerCompat 9import android.support.v4.media.session.PlaybackStateCompat 10import android.util.Log 11import androidx.lifecycle.LiveData 12import androidx.lifecycle.MutableLiveData 13import androidx.lifecycle.ViewModel 14import androidx.lifecycle.viewModelScope 15import com.example.bloggeradioapp.data.model.RadioStation 16import com.example.bloggeradioapp.data.source.RadioStationRepository 17import com.example.bloggeradioapp.playback.PlaybackService 18import kotlinx.coroutines.flow.MutableStateFlow 19import kotlinx.coroutines.flow.StateFlow 20import kotlinx.coroutines.flow.asStateFlow 21import kotlinx.coroutines.launch 22 23class SharedViewModel(private val repository: RadioStationRepository) : ViewModel() { 24 25 // LiveData for all radio stations from Blogger 26 private val _allRadioStations = MutableLiveData<List<RadioStation>>() 27 val allRadioStations: LiveData<List<RadioStation>> = _allRadioStations 28 29 // LiveData for favorite radio stations 30 private val _favoriteRadioStations = MutableLiveData<List<RadioStation>>() 31 val favoriteRadioStations: LiveData<List<RadioStation>> = _favoriteRadioStations 32 33 // StateFlow for current playing station 34 private val _currentPlayingStation = MutableStateFlow<RadioStation?>(null) 35 val currentPlayingStation: StateFlow<RadioStation?> = _currentPlayingStation.asStateFlow() 36 37 // StateFlow for playback state (playing, paused, buffering, etc.) 38 private val _playbackState = MutableStateFlow(PlaybackStateCompat.STATE_NONE) 39 val playbackState: StateFlow<Int> = _playbackState.asStateFlow() 40 41 private lateinit var mediaBrowser: MediaBrowserCompat 42 private var mediaController: MediaControllerCompat? = null 43 44 // Call this from MainActivity's onCreate 45 fun initMediaBrowser(context: Context) { 46 mediaBrowser = MediaBrowserCompat( 47 context, 48 ComponentName(context, PlaybackService::class.java), 49 connectionCallbacks, 50 null // no root extras 51 ) 52 mediaBrowser.connect() 53 } 54 55 // Call this from MainActivity's onDestroy 56 fun disconnectMediaBrowser() { 57 mediaController?.unregisterCallback(mediaControllerCallback) 58 mediaBrowser.disconnect() 59 } 60 61 fun fetchAllRadioStations() { 62 viewModelScope.launch { 63 repository.getRadioStations().collect { stations -> 64 _allRadioStations.postValue(stations) 65 } 66 } 67 } 68 69 fun fetchFavoriteRadioStations() { 70 viewModelScope.launch { 71 repository.getFavoriteStations().collect { favorites -> 72 _favoriteRadioStations.postValue(favorites) 73 } 74 } 75 } 76 77 fun toggleFavorite(station: RadioStation) { 78 viewModelScope.launch { 79 if (station.isFavorite) { 80 repository.removeFavorite(station.id) 81 } else { 82 repository.addFavorite(station) 83 } 84 // Re-fetch all stations and favorites to update UI 85 fetchAllRadioStations() 86 fetchFavoriteRadioStations() 87 } 88 } 89 90 fun playRadioStation(station: RadioStation) { 91 if (!mediaBrowser.isConnected) { 92 Log.e("SharedViewModel", "MediaBrowser not connected, cannot play station.") 93 return 94 } 95 val extras = Bundle().apply { putParcelable("radio_station", station) } 96 mediaController?.transportControls?.playFromMediaId(station.id, extras) 97 _currentPlayingStation.value = station // Optimistic update 98 } 99 100 fun togglePlayback() { 101 when (_playbackState.value) { 102 PlaybackStateCompat.STATE_PLAYING -> mediaController?.transportControls?.pause() 103 PlaybackStateCompat.STATE_PAUSED, PlaybackStateCompat.STATE_STOPPED, PlaybackStateCompat.STATE_NONE -> { 104 if (_currentPlayingStation.value != null) { 105 mediaController?.transportControls?.play() 106 } else { 107 // No station selected, maybe play the first one or show a message 108 Log.w("SharedViewModel", "No station selected to play/resume.") 109 } 110 } 111 } 112 } 113 114 fun stopPlayback() { 115 mediaController?.transportControls?.stop() 116 } 117 118 private val connectionCallbacks = object : MediaBrowserCompat.ConnectionCallback() { 119 override fun onConnected() { 120 Log.d("SharedViewModel", "MediaBrowser connected.") 121 mediaController = MediaControllerCompat(mediaBrowser.context, mediaBrowser.sessionToken).apply { 122 registerCallback(mediaControllerCallback) 123 } 124 // Request current state from service upon connection 125 requestInitialState() 126 } 127 128 override fun onConnectionSuspended() { 129 Log.w("SharedViewModel", "MediaBrowser connection suspended.") 130 _playbackState.value = PlaybackStateCompat.STATE_ERROR // Or a custom state 131 mediaController?.unregisterCallback(mediaControllerCallback) 132 mediaController = null 133 } 134 135 override fun onConnectionFailed() { 136 Log.e("SharedViewModel", "MediaBrowser connection failed.") 137 _playbackState.value = PlaybackStateCompat.STATE_ERROR 138 } } 139 140 private val mediaControllerCallback = object : MediaControllerCompat.Callback() { 141 override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { 142 _playbackState.value = state?.state ?: PlaybackStateCompat.STATE_NONE 143 Log.d("SharedViewModel", "Playback state changed: ${state?.state}") 144 } 145 146 override fun onMetadataChanged(metadata: MediaMetadataCompat?) { 147 val id = metadata?.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) 148 val name = metadata?.getString(MediaMetadataCompat.METADATA_KEY_TITLE) 149 val artist = metadata?.getString(MediaMetadataCompat.METADATA_KEY_ARTIST) 150 val imageUrl = metadata?.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI) 151 152 if (id != null && name != null) { 153 _currentPlayingStation.value = RadioStation( 154 id = id, 155 name = name, 156 streamUrl = "", // Not available from metadata directly 157 imageUrl = imageUrl, 158 category = artist, 159 description = null, 160 isFavorite = false // Will be updated by repository fetch if it's a favorite 161 ) 162 // Also update favorite status by re-fetching all stations 163 fetchAllRadioStations() 164 } else { 165 _currentPlayingStation.value = null 166 } 167 Log.d("SharedViewModel", "Metadata changed: ${name}") 168 } 169 } 170 171 private fun requestInitialState() { 172 // This is a workaround as MediaBrowserCompat doesn't directly provide initial state. 173 // We'll call a method on the service via the MediaController. 174 // This requires the PlaybackService to have a way to expose its current state. 175 // For simplicity, the service will update metadata/state on its own after connection. 176 // Or, we can expose a custom action. For now, rely on `onMetadataChanged` and `onPlaybackStateChanged` 177 // which are triggered upon connection. 178 } 179}
copy
app/src/main/java/com/example/bloggeradioapp/ui/SharedViewModelFactory.kt
copykotlin
1package com.example.bloggeradioapp.ui 2 3import android.content.Context 4import androidx.lifecycle.ViewModel 5import androidx.lifecycle.ViewModelProvider 6import com.example.bloggeradioapp.data.local.RadioStationDatabase 7import com.example.bloggeradioapp.data.source.BloggerRssParser 8import com.example.bloggeradioapp.data.source.RadioStationRepository 9import okhttp3.OkHttpClient 10 11class SharedViewModelFactory(private val context: Context) : ViewModelProvider.Factory { 12 override fun <T : ViewModel> create(modelClass: Class<T>): T { 13 if (modelClass.isAssignableFrom(SharedViewModel::class.java)) { 14 val database = RadioStationDatabase.getDatabase(context) 15 val radioStationDao = database.radioStationDao() 16 val okHttpClient = OkHttpClient() 17 val bloggerRssParser = BloggerRssParser(okHttpClient) 18 19 // IMPORTANT: Replace with your actual blog RSS feed URL 20 val bloggerFeedUrl = "https://seebatorefmtamil.blogspot.com/feeds/posts/default" 21 22 val repository = RadioStationRepository(bloggerRssParser, radioStationDao, bloggerFeedUrl) 23 @Suppress("UNCHECKED_CAST") 24 return SharedViewModel(repository) as T 25 } 26 throw IllegalArgumentException("Unknown ViewModel class") 27 } 28}
copy
Remember to replace bloggerFeedUrl
with your actual Blogger RSS feed URL in SharedViewModelFactory.kt
!
7. UI Layouts
Create the XML layout files as per the design.
app/src/main/res/layout/activity_main.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<androidx.constraintlayout.widget.ConstraintLayout 3 xmlns:android="http://schemas.android.com/apk/res/android" 4 xmlns:app="http://schemas.android.com/apk/res-auto" 5 xmlns:tools="http://schemas.android.com/tools" 6 android:layout_width="match_parent" 7 android:layout_height="match_parent" 8 tools:context=".MainActivity"> 9 10 <androidx.fragment.app.FragmentContainerView 11 android:id="@+id/nav_host_fragment" 12 android:name="androidx.navigation.fragment.NavHostFragment" 13 android:layout_width="0dp" 14 android:layout_height="0dp" 15 app:layout_constraintBottom_toTopOf="@id/mini_player_container" 16 app:layout_constraintEnd_toEndOf="parent" 17 app:layout_constraintStart_toStartOf="parent" 18 app:layout_constraintTop_toTopOf="parent" 19 app:defaultNavHost="true" 20 app:navGraph="@navigation/nav_graph" /> 21 22 <!-- Mini Player Container --> 23 <androidx.constraintlayout.widget.ConstraintLayout 24 android:id="@+id/mini_player_container" 25 android:layout_width="0dp" 26 android:layout_height="wrap_content" 27 android:background="?attr/colorSurface" 28 android:elevation="8dp" 29 app:layout_constraintBottom_toTopOf="@id/bottom_navigation" 30 app:layout_constraintEnd_toEndOf="parent" 31 app:layout_constraintStart_toStartOf="parent"> 32 33 <include 34 android:id="@+id/mini_player_layout" 35 layout="@layout/layout_mini_player" 36 android:layout_width="0dp" 37 android:layout_height="wrap_content" 38 app:layout_constraintBottom_toBottomOf="parent" 39 app:layout_constraintEnd_toEndOf="parent" 40 app:layout_constraintStart_toStartOf="parent" 41 app:layout_constraintTop_toTopOf="parent" /> 42 43 </androidx.constraintlayout.widget.ConstraintLayout> 44 45 <com.google.android.material.bottomnavigation.BottomNavigationView 46 android:id="@+id/bottom_navigation" 47 android:layout_width="0dp" 48 android:layout_height="wrap_content" 49 app:layout_constraintBottom_toBottomOf="parent" 50 app:layout_constraintEnd_toEndOf="parent" 51 app:layout_constraintStart_toStartOf="parent" 52 app:menu="@menu/bottom_nav_menu" 53 app:labelVisibilityMode="labeled" 54 app:itemIconTint="@color/bottom_nav_item_color" 55 app:itemTextColor="@color/bottom_nav_item_color" /> 56 57</androidx.constraintlayout.widget.ConstraintLayout>
copy
app/src/main/res/layout/layout_mini_player.xml
```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp" android:background="?attr/colorSurface" android:clickable="true" android:focusable="true"> <com.google.android.material.imageview.ShapeableImageView android:id="@+id/mini_player_image" android:layout_width="56dp" android:layout_height="56dp" android:scaleType="centerCrop" android:src="@drawable/ic_radio_default_art" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.App.CornerSize50Percent" />
copyjavascript
1<TextView 2 android:id="@+id/mini_player_title" 3 android:layout_width="0dp" 4 android:layout_height="wrap_content" 5 android:layout_marginStart="8dp" 6 android:layout_marginEnd="8dp" 7 android:singleLine="true" 8 android:ellipsize="marquee" 9 android:marqueeRepeatLimit="marquee_forever" 10 android:focusable="true" 11 android:focusableInTouchMode="true" 12 android:scrollHorizontally="true" 13 android:textStyle="bold" 14 android:textColor="?attr/colorOnSurface" 15 android:textSize="16sp" 16 app:layout_constraintStart_toEndOf="@id/mini_player_image" 17 app:layout_constraintEnd_toStartOf="@id/mini_player_rewind" 18 app:layout_constraintTop_toTopOf="@id/mini_player_image" 19 tools:text="Radio Niagara" /> 20 21<TextView 22 android:id="@+id/mini_player_subtitle" 23 android:layout_width="0dp" 24 android:layout_height="wrap_content" 25 android:layout_marginStart="8dp" 26 android:layout_marginEnd="8dp" 27 android:singleLine="true" 28 android:ellipsize="end" 29 android:textColor="?attr/colorOnSurfaceVariant" 30 android:textSize="14sp" 31 app:layout_constraintStart_toEndOf="@id/mini_player_image" 32 app:layout_constraintEnd_toStartOf="@id/mini_player_rewind" 33 app:layout_constraintTop_toBottomOf="@id/mini_player_title" 34 tools:text="- August 22, 2020 [0]" /> 35 36<ImageButton 37 android:id="@+id/mini_player_rewind" 38 android:layout_width="48dp" 39 android:layout_height="48dp" 40 android:background="?attr/selectableItemBackgroundBorderless" 41 android:contentDescription="@string/rewind_button_description" 42 android:src="@drawable/ic_skip_previous" 43 app:layout_constraintEnd_toStartOf="@id/mini_player_play_pause" 44 app:layout_constraintTop_toTopOf="parent" 45 app:layout_constraintBottom_toBottomOf="parent" /> 46 47<ImageButton 48 android:id="@+id/mini_player_play_pause" 49 android:layout_width="48dp" 50 android:layout_height="48dp" 51 android:background="?attr/selectableItemBackgroundBorderless" 52 android:contentDescription="@string/play_pause_button_description" 53 android:src="@drawable/ic_play_arrow" 54 app:layout_constraintEnd_toStartOf="@id/mini_player_fast_forward" 55 app:layout_constraintTop_toTopOf="parent" 56 app:layout_constraintBottom_toBottomOf="parent" /> 57 58<ImageButton 59 android:id="@+id/mini_player_fast_forward" 60 android:layout_width="48dp" 61 android:layout_height="48dp" 62 android:background="?attr/selectableItemBackgroundBorderless" 63 android:contentDescription="@string/fast_forward_button_description" 64 android:src="@drawable/ic_skip_next" 65 app:layout_constraintEnd_toEndOf="parent" 66 app:layout_constraintTop_toTopOf="parent" 67 app:layout_constraintBottom_toBottomOf="parent" />
copy
</androidx.constraintlayout.widget.ConstraintLayout>
copyjavascript
1 2**`app/src/main/res/layout/fragment_home.xml`** 3 4```xml 5<?xml version="1.0" encoding="utf-8"?> 6<androidx.constraintlayout.widget.ConstraintLayout 7 xmlns:android="http://schemas.android.com/apk/res/android" 8 xmlns:app="http://schemas.android.com/apk/res-auto" 9 xmlns:tools="http://schemas.android.com/tools" 10 android:layout_width="match_parent" 11 android:layout_height="match_parent" 12 tools:context=".ui.home.HomeFragment"> 13 14 <com.google.android.material.appbar.MaterialToolbar 15 android:id="@+id/toolbar" 16 android:layout_width="0dp" 17 android:layout_height="?attr/actionBarSize" 18 android:background="?attr/colorPrimary" 19 app:title="@string/app_name" 20 app:titleTextColor="?attr/colorOnPrimary" 21 app:layout_constraintStart_toStartOf="parent" 22 app:layout_constraintEnd_toEndOf="parent" 23 app:layout_constraintTop_toTopOf="parent" 24 app:menu="@menu/toolbar_menu" /> 25 26 <androidx.recyclerview.widget.RecyclerView 27 android:id="@+id/radio_stations_recycler_view" 28 android:layout_width="0dp" 29 android:layout_height="0dp" 30 app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" 31 app:layout_constraintBottom_toBottomOf="parent" 32 app:layout_constraintEnd_toEndOf="parent" 33 app:layout_constraintStart_toStartOf="parent" 34 app:layout_constraintTop_toBottomOf="@id/toolbar" 35 tools:listitem="@layout/list_item_radio_station" /> 36 37 <ProgressBar 38 android:id="@+id/loading_spinner" 39 android:layout_width="wrap_content" 40 android:layout_height="wrap_content" 41 android:visibility="gone" 42 app:layout_constraintBottom_toBottomOf="parent" 43 app:layout_constraintEnd_toEndOf="parent" 44 app:layout_constraintStart_toStartOf="parent" 45 app:layout_constraintTop_toBottomOf="@id/toolbar" /> 46 47 <TextView 48 android:id="@+id/error_message" 49 android:layout_width="wrap_content" 50 android:layout_height="wrap_content" 51 android:text="@string/error_loading_stations" 52 android:visibility="gone" 53 app:layout_constraintBottom_toBottomOf="parent" 54 app:layout_constraintEnd_toEndOf="parent" 55 app:layout_constraintStart_toStartOf="parent" 56 app:layout_constraintTop_toBottomOf="@id/toolbar" /> 57 58</androidx.constraintlayout.widget.ConstraintLayout>
copy
app/src/main/res/layout/list_item_radio_station.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<com.google.android.material.card.MaterialCardView 3 xmlns:android="http://schemas.android.com/apk/res/android" 4 xmlns:app="http://schemas.android.com/apk/res-auto" 5 xmlns:tools="http://schemas.android.com/tools" 6 android:layout_width="match_parent" 7 android:layout_height="wrap_content" 8 android:layout_marginStart="8dp" 9 android:layout_marginEnd="8dp" android:layout_marginTop="4dp" 10 android:layout_marginBottom="4dp" 11 app:cardCornerRadius="8dp" 12 app:cardElevation="2dp" 13 app:rippleColor="?attr/colorPrimaryVariant"> 14 15 <androidx.constraintlayout.widget.ConstraintLayout 16 android:layout_width="match_parent" 17 android:layout_height="wrap_content" 18 android:padding="8dp"> 19 20 <com.google.android.material.imageview.ShapeableImageView 21 android:id="@+id/station_image" 22 android:layout_width="64dp" 23 android:layout_height="64dp" 24 android:scaleType="centerCrop" 25 android:src="@drawable/ic_radio_default_art" 26 app:layout_constraintStart_toStartOf="parent" 27 app:layout_constraintTop_toTopOf="parent" 28 app:layout_constraintBottom_toBottomOf="parent" 29 app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.App.CornerSize50Percent" /> 30 31 <TextView 32 android:id="@+id/station_name" 33 android:layout_width="0dp" 34 android:layout_height="wrap_content" 35 android:layout_marginStart="12dp" 36 android:layout_marginEnd="8dp" 37 android:textStyle="bold" 38 android:textSize="16sp" 39 android:textColor="?attr/colorOnSurface" 40 android:singleLine="true" 41 android:ellipsize="end" 42 app:layout_constraintStart_toEndOf="@id/station_image" 43 app:layout_constraintEnd_toStartOf="@id/more_options_button" 44 app:layout_constraintTop_toTopOf="@id/station_image" 45 app:layout_constraintBottom_toTopOf="@id/station_category" 46 app:layout_constraintVertical_chainStyle="packed" 47 tools:text="Smart FM Indonesia" /> 48 49 <TextView 50 android:id="@+id/station_category" 51 android:layout_width="0dp" 52 android:layout_height="wrap_content" 53 android:layout_marginStart="12dp" 54 android:layout_marginEnd="8dp" 55 android:textSize="14sp" 56 android:textColor="?attr/colorOnSurfaceVariant" 57 android:singleLine="true" 58 android:ellipsize="end" 59 app:layout_constraintStart_toEndOf="@id/station_image" 60 app:layout_constraintEnd_toStartOf="@id/more_options_button" 61 app:layout_constraintTop_toBottomOf="@id/station_name" 62 app:layout_constraintBottom_toBottomOf="@id/station_image" 63 tools:text="Culture" /> 64 65 <ImageButton 66 android:id="@+id/more_options_button" 67 android:layout_width="40dp" 68 android:layout_height="40dp" 69 android:background="?attr/selectableItemBackgroundBorderless" 70 android:src="@drawable/ic_more_vert" 71 android:contentDescription="@string/more_options_description" 72 app:layout_constraintEnd_toEndOf="parent" 73 app:layout_constraintTop_toTopOf="parent" 74 app:layout_constraintBottom_toBottomOf="parent" /> 75 76 </androidx.constraintlayout.widget.ConstraintLayout> 77</com.google.android.material.card.MaterialCardView>
copy
app/src/main/res/layout/fragment_player.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<androidx.constraintlayout.widget.ConstraintLayout 3 xmlns:android="http://schemas.android.com/apk/res/android" 4 xmlns:app="http://schemas.android.com/apk/res-auto" 5 xmlns:tools="http://schemas.android.com/tools" 6 android:layout_width="match_parent" 7 android:layout_height="match_parent" 8 android:background="?attr/colorPrimaryDark" 9 tools:context=".ui.player.PlayerFragment"> 10 11 <com.google.android.material.appbar.MaterialToolbar 12 android:id="@+id/player_toolbar" 13 android:layout_width="0dp" 14 android:layout_height="?attr/actionBarSize" 15 app:layout_constraintEnd_toEndOf="parent" 16 app:layout_constraintStart_toStartOf="parent" 17 app:layout_constraintTop_toTopOf="parent" 18 app:navigationIcon="@drawable/ic_arrow_down" 19 app:navigationIconTint="?attr/colorOnPrimary" 20 app:menu="@menu/player_toolbar_menu" /> 21 22 <com.google.android.material.imageview.ShapeableImageView 23 android:id="@+id/player_image" 24 android:layout_width="0dp" 25 android:layout_height="0dp" 26 android:layout_margin="32dp" 27 android:scaleType="centerCrop" 28 android:src="@drawable/ic_radio_default_art" 29 app:layout_constraintDimensionRatio="1:1" 30 app:layout_constraintEnd_toEndOf="parent" 31 app:layout_constraintStart_toStartOf="parent" 32 app:layout_constraintTop_toBottomOf="@id/player_toolbar" 33 app:layout_constraintWidth_percent="0.7" 34 app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.App.CornerSize8dp" /> 35 36 <TextView 37 android:id="@+id/playback_indicator" 38 android:layout_width="wrap_content" 39 android:layout_height="wrap_content" 40 android:layout_marginTop="16dp" 41 android:text="❚❚❚" 42 android:textColor="?attr/colorOnPrimary" 43 android:textSize="24sp" 44 android:textStyle="bold" 45 android:visibility="invisible" 46 app:layout_constraintEnd_toEndOf="parent" 47 app:layout_constraintStart_toStartOf="parent" 48 app:layout_constraintTop_toBottomOf="@id/player_image" 49 tools:visibility="visible" /> 50 51 <TextView 52 android:id="@+id/player_title" 53 android:layout_width="0dp" 54 android:layout_height="wrap_content" 55 android:layout_marginStart="16dp" 56 android:layout_marginTop="8dp" 57 android:layout_marginEnd="16dp" 58 android:gravity="center" 59 android:singleLine="true" 60 android:ellipsize="marquee" 61 android:marqueeRepeatLimit="marquee_forever" 62 android:focusable="true" 63 android:focusableInTouchMode="true" 64 android:scrollHorizontally="true" 65 android:textColor="?attr/colorOnPrimary" 66 android:textSize="24sp" 67 android:textStyle="bold" 68 app:layout_constraintEnd_toEndOf="parent" 69 app:layout_constraintStart_toStartOf="parent" 70 app:layout_constraintTop_toBottomOf="@id/playback_indicator" tools:text="Radio Niagara" /> 71 72 <TextView 73 android:id="@+id/player_subtitle" 74 android:layout_width="0dp" 75 android:layout_height="wrap_content" 76 android:layout_marginStart="16dp" 77 android:layout_marginEnd="16dp" 78 android:gravity="center" 79 android:singleLine="true" 80 android:ellipsize="end" 81 android:textColor="?attr/colorOnPrimaryVariant" 82 android:textSize="16sp" 83 app:layout_constraintEnd_toEndOf="parent" 84 app:layout_constraintStart_toStartOf="parent" 85 app:layout_constraintTop_toBottomOf="@id/player_title" 86 tools:text="- August 22, 2020 [0]" /> 87 88 <LinearLayout 89 android:id="@+id/player_controls_layout" 90 android:layout_width="0dp" 91 android:layout_height="wrap_content" 92 android:orientation="horizontal" 93 android:gravity="center" 94 android:layout_marginTop="32dp" 95 android:layout_marginBottom="32dp" 96 app:layout_constraintBottom_toBottomOf="parent" 97 app:layout_constraintEnd_toEndOf="parent" 98 app:layout_constraintStart_toStartOf="parent" 99 app:layout_constraintTop_toBottomOf="@id/player_subtitle" 100 app:layout_constraintVertical_bias="0.9"> 101 102 <ImageButton 103 android:id="@+id/btn_timer" 104 android:layout_width="56dp" 105 android:layout_height="56dp" 106 android:background="?attr/selectableItemBackgroundBorderless" 107 android:contentDescription="@string/timer_button_description" 108 android:src="@drawable/ic_timer" 109 android:tint="?attr/colorOnPrimary" /> 110 111 <ImageButton android:id="@+id/btn_rewind" 112 android:layout_width="56dp" 113 android:layout_height="56dp" 114 android:layout_marginStart="16dp" 115 android:background="?attr/selectableItemBackgroundBorderless" 116 android:contentDescription="@string/rewind_button_description" 117 android:src="@drawable/ic_skip_previous" 118 android:tint="?attr/colorOnPrimary" /> 119 120 <com.google.android.material.floatingactionbutton.FloatingActionButton 121 android:id="@+id/btn_play_pause" 122 android:layout_width="wrap_content" 123 android:layout_height="wrap_content" 124 android:layout_marginStart="16dp" 125 android:layout_marginEnd="16dp" 126 android:clickable="true" 127 android:focusable="true" 128 android:src="@drawable/ic_play_arrow_filled" 129 android:contentDescription="@string/play_pause_button_description" 130 app:backgroundTint="?attr/colorAccent" 131 app:tint="?attr/colorOnAccent" 132 app:fabSize="normal" /> 133 134 <ImageButton 135 android:id="@+id/btn_fast_forward" 136 android:layout_width="56dp" 137 android:layout_height="56dp" 138 android:layout_marginEnd="16dp" 139 android:background="?attr/selectableItemBackgroundBorderless" 140 android:contentDescription="@string/fast_forward_button_description" 141 android:src="@drawable/ic_skip_next" 142 android:tint="?attr/colorOnPrimary" /> 143 144 <ImageButton 145 android:id="@+id/btn_volume" 146 android:layout_width="56dp" 147 android:layout_height="56dp" 148 android:background="?attr/selectableItemBackgroundBorderless" 149 android:contentDescription="@string/volume_button_description" 150 android:src="@drawable/ic_volume_up" 151 android:tint="?attr/colorOnPrimary" /> 152 153 </LinearLayout> 154 155</androidx.constraintlayout.widget.ConstraintLayout> 156```**`app/src/main/res/layout/fragment_favorite.xml`** 157 158```xml 159<?xml version="1.0" encoding="utf-8"?> 160<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" 161 xmlns:app="http://schemas.android.com/apk/res-auto" 162 xmlns:tools="http://schemas.android.com/tools" 163 android:layout_width="match_parent" 164 android:layout_height="match_parent" 165 tools:context=".ui.favorite.FavoriteFragment"> 166 167 <com.google.android.material.appbar.MaterialToolbar 168 android:id="@+id/toolbar" 169 android:layout_width="0dp" 170 android:layout_height="?attr/actionBarSize" 171 android:background="?attr/colorPrimary" 172 app:title="@string/favorite_title" 173 app:titleTextColor="?attr/colorOnPrimary" 174 app:layout_constraintStart_toStartOf="parent" 175 app:layout_constraintEnd_toEndOf="parent" 176 app:layout_constraintTop_toTopOf="parent" 177 app:menu="@menu/toolbar_menu" /> 178 179 <androidx.recyclerview.widget.RecyclerView 180 android:id="@+id/favorite_stations_recycler_view" 181 android:layout_width="0dp" 182 android:layout_height="0dp" 183 app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" 184 app:layout_constraintBottom_toBottomOf="parent" 185 app:layout_constraintEnd_toEndOf="parent" 186 app:layout_constraintStart_toStartOf="parent" 187 app:layout_constraintTop_toBottomOf="@id/toolbar" 188 tools:listitem="@layout/list_item_radio_station" /> 189 190 <LinearLayout 191 android:id="@+id/empty_state_view" 192 android:layout_width="wrap_content" 193 android:layout_height="wrap_content" 194 android:orientation="vertical" 195 android:gravity="center" 196 android:visibility="gone" 197 app:layout_constraintBottom_toBottomOf="parent" 198 app:layout_constraintEnd_toEndOf="parent" 199 app:layout_constraintStart_toStartOf="parent" 200 app:layout_constraintTop_toBottomOf="@id/toolbar" 201 tools:visibility="visible"> 202 203 <ImageView 204 android:layout_width="120dp" 205 android:layout_height="120dp" 206 android:src="@drawable/ic_heart_outline" 207 android:tint="?attr/colorOnSurfaceVariant" 208 android:contentDescription="@string/empty_favorite_icon_description" /> 209 210 <TextView 211 android:layout_width="wrap_content" 212 android:layout_height="wrap_content" 213 android:layout_marginTop="16dp" 214 android:text="@string/whoops_title" 215 android:textSize="20sp" 216 android:textStyle="bold" 217 android:textColor="?attr/colorOnSurface" /> 218 219 <TextView 220 android:layout_width="wrap_content" 221 android:layout_height="wrap_content" 222 android:layout_marginTop="8dp" 223 android:layout_marginStart="32dp" 224 android:layout_marginEnd="32dp" 225 android:gravity="center" 226 android:text="@string/empty_favorite_message" 227 android:textColor="?attr/colorOnSurfaceVariant" 228 android:textSize="14sp" /> 229 230 </LinearLayout> 231 232</androidx.constraintlayout.widget.ConstraintLayout>
copy
app/src/main/res/layout/fragment_category.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout 2 xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:app="http://schemas.android.com/apk/res-auto" 4 xmlns:tools="http://schemas.android.com/tools" 5 android:layout_width="match_parent" 6 android:layout_height="match_parent" 7 tools:context=".ui.category.CategoryFragment"> 8 9 <com.google.android.material.appbar.MaterialToolbar 10 android:id="@+id/toolbar" 11 android:layout_width="0dp" 12 android:layout_height="?attr/actionBarSize" 13 android:background="?attr/colorPrimary" 14 app:title="@string/category_title" 15 app:titleTextColor="?attr/colorOnPrimary" 16 app:layout_constraintStart_toStartOf="parent" 17 app:layout_constraintEnd_toEndOf="parent" 18 app:layout_constraintTop_toTopOf="parent" 19 app:menu="@menu/toolbar_menu" /> 20 21 <TextView 22 android:layout_width="wrap_content" 23 android:layout_height="wrap_content" 24 android:text="@string/category_fragment_placeholder" 25 android:textSize="18sp" 26 app:layout_constraintBottom_toBottomOf="parent" 27 app:layout_constraintEnd_toEndOf="parent" 28 app:layout_constraintStart_toStartOf="parent" 29 app:layout_constraintTop_toBottomOf="@id/toolbar" /> 30 31</androidx.constraintlayout.widget.ConstraintLayout>
copy
8. UI Fragments
Now, let's create the Fragment classes.
app/src/main/java/com/example/bloggeradioapp/ui/home/HomeFragment.kt
copykotlin
1package com.example.bloggeradioapp.ui.home 2 3import android.os.Bundleimport android.util.Log 4import android.view.LayoutInflater 5import android.view.View 6import android.view.ViewGroup 7import android.widget.Toast 8import androidx.fragment.app.Fragment 9import androidx.fragment.app.activityViewModels 10import androidx.lifecycle.Lifecycle 11import androidx.lifecycle.lifecycleScope 12import androidx.lifecycle.repeatOnLifecycle 13import androidx.navigation.fragment.findNavController 14import com.example.bloggeradioapp.R 15import com.example.bloggeradioapp.data.model.RadioStation 16import com.example.bloggeradioapp.databinding.FragmentHomeBinding 17import com.example.bloggeradioapp.ui.SharedViewModel 18import com.example.bloggeradioapp.ui.player.PlayerFragmentDirections 19import kotlinx.coroutines.launch 20 21class HomeFragment : Fragment() { 22 23 private var _binding: FragmentHomeBinding? = null 24 private val binding get() = _binding!! 25 26 // Use activityViewModels() to get a ViewModel shared with the hosting Activity 27 private val sharedViewModel: SharedViewModel by activityViewModels() 28 29 private lateinit var radioStationAdapter: RadioStationAdapter 30 31 override fun onCreateView( 32 inflater: LayoutInflater, container: ViewGroup?, 33 savedInstanceState: Bundle? 34 ): View { 35 _binding = FragmentHomeBinding.inflate(inflater, container, false) 36 return binding.root 37 } 38 39 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 40 super.onViewCreated(view, savedInstanceState) 41 42 setupToolbar() 43 setupRecyclerView() 44 observeViewModel() 45 46 // Fetch stations when the fragment is created 47 sharedViewModel.fetchAllRadioStations() 48 } 49 50 private fun setupToolbar() { 51 binding.toolbar.setOnMenuItemClickListener { menuItem -> 52 when (menuItem.itemId) { 53 R.id.action_search -> { 54 Toast.makeText(context, "Search clicked", Toast.LENGTH_SHORT).show() 55 true 56 } 57 R.id.action_settings -> { 58 Toast.makeText(context, "Settings clicked", Toast.LENGTH_SHORT).show() 59 true 60 } 61 else -> false 62 } 63 } 64 } 65 66 private fun setupRecyclerView() { 67 radioStationAdapter = RadioStationAdapter( 68 onItemClick = { station -> 69 sharedViewModel.playRadioStation(station) 70 findNavController().navigate(R.id.action_global_playerFragment) 71 }, 72 onFavoriteClick = { station -> sharedViewModel.toggleFavorite(station) 73 }, 74 onMoreOptionsClick = { station -> 75 Toast.makeText(context, "More options for ${station.name}", Toast.LENGTH_SHORT).show() 76 } 77 ) 78 binding.radioStationsRecyclerView.adapter = radioStationAdapter 79 } 80 81 private fun observeViewModel() { 82 viewLifecycleOwner.lifecycleScope.launch { 83 viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 84 sharedViewModel.allRadioStations.observe(viewLifecycleOwner) { stations -> 85 Log.d("HomeFragment", "Observed ${stations.size} radio stations.") 86 if (stations.isNotEmpty()) { 87 radioStationAdapter.submitList(stations) 88 binding.loadingSpinner.visibility = View.GONE 89 binding.error_message.visibility = View.GONE 90 binding.radioStationsRecyclerView.visibility = View.VISIBLE 91 } else { 92 binding.loadingSpinner.visibility = View.GONE 93 binding.error_message.visibility = View.VISIBLE 94 binding.radioStationsRecyclerView.visibility = View.GONE 95 binding.error_message.text = getString(R.string.no_stations_found) 96 } 97 } 98 99 // Observe playback state to update UI if needed (e.g., currently playing icon) 100 sharedViewModel.currentPlayingStation.collect { station -> 101 radioStationAdapter.setCurrentPlayingStation(station) 102 } 103 104 // Show loading spinner while data is being fetched initially 105 if (sharedViewModel.allRadioStations.value.isNullOrEmpty()) { 106 binding.loadingSpinner.visibility = View.VISIBLE 107 binding.radioStationsRecyclerView.visibility = View.GONE 108 binding.error_message.visibility = View.GONE 109 } 110 } 111 } 112 } 113 114 override fun onDestroyView() { 115 super.onDestroyView() 116 _binding = null 117 } 118}
copy
app/src/main/java/com/example/bloggeradioapp/ui/home/RadioStationAdapter.kt
copykotlin
1package com.example.bloggeradioapp.ui.home 2 3import android.view.LayoutInflater 4import android.view.View 5import android.view.ViewGroup 6import androidx.core.content.ContextCompat 7import androidx.recyclerview.widget.DiffUtil 8import androidx.recyclerview.widget.ListAdapter 9import androidx.recyclerview.widget.RecyclerView 10import com.bumptech.glide.Glide 11import com.example.bloggeradioapp.Rimport com.example.bloggeradioapp.data.model.RadioStation 12import com.example.bloggeradioapp.databinding.ListItemRadioStationBinding 13 14class RadioStationAdapter( 15 private val onItemClick: (RadioStation) -> Unit, private val onFavoriteClick: (RadioStation) -> Unit, 16 private val onMoreOptionsClick: (RadioStation) -> Unit 17) : ListAdapter<RadioStation, RadioStationAdapter.RadioStationViewHolder>(RadioStationDiffCallback()) { 18 19 private var currentPlayingStation: RadioStation? = null 20 21 fun setCurrentPlayingStation(station: RadioStation?) { 22 val oldPlayingStation = currentPlayingStation 23 currentPlayingStation = station 24 // Notify changes to update UI for playing indicator 25 if (oldPlayingStation != null) { 26 val oldIndex = currentList.indexOfFirst { it.id == oldPlayingStation.id } 27 if (oldIndex != -1) notifyItemChanged(oldIndex) 28 } 29 if (currentPlayingStation != null) { 30 val newIndex = currentList.indexOfFirst { it.id == currentPlayingStation?.id } 31 if (newIndex != -1) notifyItemChanged(newIndex) 32 } 33 } 34 35 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadioStationViewHolder { 36 val binding = ListItemRadioStationBinding.inflate( 37 LayoutInflater.from(parent.context), 38 parent, 39 false 40 ) 41 return RadioStationViewHolder(binding) 42 } 43 44 override fun onBindViewHolder(holder: RadioStationViewHolder, position: Int) { 45 val station = getItem(position) 46 holder.bind(station, onItemClick, onFavoriteClick, onMoreOptionsClick, currentPlayingStation) 47 } 48 49 class RadioStationViewHolder(private val binding: ListItemRadioStationBinding) : 50 RecyclerView.ViewHolder(binding.root) { 51 52 fun bind( 53 station: RadioStation, 54 onItemClick: (RadioStation) -> Unit, 55 onFavoriteClick: (RadioStation) -> Unit, 56 onMoreOptionsClick: (RadioStation) -> Unit, 57 currentPlayingStation: RadioStation? 58 ) { 59 binding.stationName.text = station.name 60 binding.stationCategory.text = station.category ?: binding.root.context.getString(R.string.misc_category) 61 62 Glide.with(binding.stationImage.context) 63 .load(station.imageUrl) 64 .placeholder(R.drawable.ic_radio_default_art) // Default placeholder 65 .error(R.drawable.ic_radio_default_art) // Error placeholder 66 .into(binding.stationImage) 67 68 binding.root.setOnClickListener { onItemClick(station) } 69 binding.moreOptionsButton.setOnClickListener { onMoreOptionsClick(station) } 70 71 // Visual indication if this station is currently playing 72 val isPlaying = station.id == currentPlayingStation?.id 73 if (isPlaying) { 74 binding.root.setCardBackgroundColor(ContextCompat.getColor(binding.root.context, R.color.playing_item_background)) 75 // You could also add a small animation or icon here 76 } else { 77 binding.root.setCardBackgroundColor(ContextCompat.getColor(binding.root.context, android.R.color.white)) 78 } 79 } 80 } 81 82 private class RadioStationDiffCallback : DiffUtil.ItemCallback<RadioStation>() { 83 override fun areItemsTheSame(oldItem: RadioStation, newItem: RadioStation): Boolean { 84 return oldItem.id == newItem.id } 85 86 override fun areContentsTheSame(oldItem: RadioStation, newItem: RadioStation): Boolean { 87 // Check all relevant fields, especially isFavorite 88 return oldItem == newItem 89 } 90 }}
copy
app/src/main/java/com/example/bloggeradioapp/ui/player/PlayerFragment.kt
copykotlin
1package com.example.bloggeradioapp.ui.player 2 3import android.graphics.drawable.Drawable 4import android.os.Bundle 5import android.view.LayoutInflater 6import android.view.View 7import android.view.ViewGroup 8import android.widget.Toast 9import androidx.core.content.ContextCompat 10import androidx.fragment.app.Fragment 11import androidx.fragment.app.activityViewModels 12import androidx.lifecycle.Lifecycle 13import androidx.lifecycle.lifecycleScope 14import androidx.lifecycle.repeatOnLifecycle 15import androidx.media3.common.Player 16import androidx.navigation.fragment.findNavController 17import com.bumptech.glide.Glide 18import com.bumptech.glide.request.target.CustomTarget 19import com.bumptech.glide.request.transition.Transition 20import com.example.bloggeradioapp.R 21import com.example.bloggeradioapp.data.model.RadioStation 22import com.example.bloggeradioapp.databinding.FragmentPlayerBinding 23import com.example.bloggeradioapp.ui.SharedViewModel 24import kotlinx.coroutines.flow.collectLatest 25import kotlinx.coroutines.launch 26 27class PlayerFragment : Fragment() { 28 29 private var _binding: FragmentPlayerBinding? = null 30 private val binding get() = _binding!! 31 32 private val sharedViewModel: SharedViewModel by activityViewModels() 33 34 override fun onCreateView( 35 inflater: LayoutInflater, container: ViewGroup?, 36 savedInstanceState: Bundle? 37 ): View { 38 _binding = FragmentPlayerBinding.inflate(inflater, container, false) 39 return binding.root 40 } 41 42 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) 43 44 setupToolbar() 45 setupClickListeners() 46 observeViewModel() 47 } 48 49 private fun setupToolbar() { 50 binding.playerToolbar.setNavigationOnClickListener { findNavController().navigateUp() // Go back to previous fragment 51 } 52 binding.playerToolbar.setOnMenuItemClickListener { menuItem -> 53 when (menuItem.itemId) { 54 R.id.action_favorite -> { 55 sharedViewModel.currentPlayingStation.value?.let { 56 sharedViewModel.toggleFavorite(it) 57 } 58 true 59 } 60 R.id.action_info -> { 61 Toast.makeText(context, "Info clicked", Toast.LENGTH_SHORT).show() 62 true 63 } 64 R.id.action_share -> { 65 Toast.makeText(context, "Share clicked", Toast.LENGTH_SHORT).show() 66 true 67 } 68 else -> false 69 } 70 } 71 } 72 73 private fun setupClickListeners() { 74 binding.btnPlayPause.setOnClickListener { 75 sharedViewModel.togglePlayback() 76 } 77 binding.btnRewind.setOnClickListener { 78 Toast.makeText(context, "Rewind (Not implemented for live streams)", Toast.LENGTH_SHORT).show() 79 // For podcasts: sharedViewModel.rewind() 80 } 81 binding.btnFastForward.setOnClickListener { 82 Toast.makeText(context, "Forward (Not implemented for live streams)", Toast.LENGTH_SHORT).show() 83 // For podcasts: sharedViewModel.fastForward() 84 } 85 binding.btnTimer.setOnClickListener { 86 Toast.makeText(context, "Sleep Timer (Not implemented)", Toast.LENGTH_SHORT).show() 87 } 88 binding.btnVolume.setOnClickListener { 89 Toast.makeText(context, "Volume control (System handled)", Toast.LENGTH_SHORT).show() 90 } 91 } 92 93 private fun observeViewModel() { 94 viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 95 launch { 96 sharedViewModel.currentPlayingStation.collectLatest { station -> 97 updateStationInfo(station) 98 updateFavoriteIcon(station?.isFavorite ?: false) 99 } 100 } 101 launch { 102 sharedViewModel.playbackState.collectLatest { state -> 103 updatePlayPauseButton(state) 104 updatePlaybackIndicator(state) 105 } 106 } 107 } 108 } 109 } 110 111 private fun updateStationInfo(station: RadioStation?) { 112 binding.playerTitle.text = station?.name ?: getString(R.string.no_station_playing) binding.playerSubtitle.text = station?.category ?: "" 113 114 // Request focus for marquee effect 115 binding.playerTitle.isSelected = true 116 117 Glide.with(this) 118 .load(station?.imageUrl) 119 .placeholder(R.drawable.ic_radio_default_art) 120 .error(R.drawable.ic_radio_default_art) 121 .into(binding.playerImage) 122 } 123 124 private fun updatePlayPauseButton(playbackState: Int) { 125 val iconRes = when (playbackState) { 126 Player.STATE_BUFFERING -> R.drawable.ic_hourglass_empty // Or a loading spinner 127 PlaybackStateCompat.STATE_PLAYING -> R.drawable.ic_pause_filled 128 else -> R.drawable.ic_play_arrow_filled 129 } 130 binding.btnPlayPause.setImageResource(iconRes) 131 } 132 133 private fun updatePlaybackIndicator(playbackState: Int) { 134 binding.playbackIndicator.visibility = if (playbackState == PlaybackStateCompat.STATE_PLAYING) View.VISIBLE else View.INVISIBLE 135 } 136 137 private fun updateFavoriteIcon(isFavorite: Boolean) { 138 val favoriteMenuItem = binding.playerToolbar.menu.findItem(R.id.action_favorite) 139 if (isFavorite) { 140 favoriteMenuItem?.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_heart_filled) 141 } else { 142 favoriteMenuItem?.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_heart_outline) 143 } 144 favoriteMenuItem?.icon?.setTint(ContextCompat.getColor(requireContext(), R.color.colorOnPrimary)) 145 } 146 147 override fun onDestroyView() { 148 super.onDestroyView() _binding = null 149 } 150}
copy
app/src/main/java/com/example/bloggeradioapp/ui/favorite/FavoriteFragment.kt
copykotlin
1package com.example.bloggeradioapp.ui.favorite 2 3import android.os.Bundle 4import android.view.LayoutInflater 5import android.view.View 6import android.view.ViewGroup 7import android.widget.Toast 8import androidx.fragment.app.Fragment 9import androidx.fragment.app.activityViewModels 10import androidx.lifecycle.Lifecycle 11import androidx.lifecycle.lifecycleScope 12import androidx.lifecycle.repeatOnLifecycle 13import androidx.navigation.fragment.findNavController 14import com.example.bloggeradioapp.R 15import com.example.bloggeradioapp.data.model.RadioStation 16import com.example.bloggeradioapp.databinding.FragmentFavoriteBinding 17import com.example.bloggeradioapp.ui.SharedViewModel 18import com.example.bloggeradioapp.ui.home.RadioStationAdapter 19import kotlinx.coroutines.launch 20 21class FavoriteFragment : Fragment() { 22 23 private var _binding: FragmentFavoriteBinding? = null 24 private val binding get() = _binding!! 25 26 private val sharedViewModel: SharedViewModel by activityViewModels() 27 28 private lateinit var favoriteRadioStationAdapter: RadioStationAdapter 29 30 override fun onCreateView( 31 inflater: LayoutInflater, container: ViewGroup?, 32 savedInstanceState: Bundle? 33 ): View { 34 _binding = FragmentFavoriteBinding.inflate(inflater, container, false) 35 return binding.root 36 } 37 38 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 super.onViewCreated(view, savedInstanceState) 40 41 setupToolbar() 42 setupRecyclerView() 43 observeViewModel() 44 45 sharedViewModel.fetchFavoriteRadioStations() 46 } 47 48 private fun setupToolbar() { 49 binding.toolbar.setOnMenuItemClickListener { menuItem -> 50 when (menuItem.itemId) { 51 R.id.action_search -> { 52 Toast.makeText(context, "Search clicked", Toast.LENGTH_SHORT).show() 53 true 54 } 55 R.id.action_settings -> { 56 Toast.makeText(context, "Settings clicked", Toast.LENGTH_SHORT).show() 57 true 58 } 59 else -> false 60 } 61 } 62 } 63 64 private fun setupRecyclerView() { 65 favoriteRadioStationAdapter = RadioStationAdapter( 66 onItemClick = { station -> 67 sharedViewModel.playRadioStation(station) 68 findNavController().navigate(R.id.action_global_playerFragment) 69 }, 70 onFavoriteClick = { station -> 71 sharedViewModel.toggleFavorite(station) 72 }, 73 onMoreOptionsClick = { station -> 74 Toast.makeText(context, "More options for ${station.name}", Toast.LENGTH_SHORT).show() 75 } ) 76 binding.favoriteStationsRecyclerView.adapter = favoriteRadioStationAdapter 77 } 78 79 private fun observeViewModel() { 80 viewLifecycleOwner.lifecycleScope.launch { 81 viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 82 sharedViewModel.favoriteRadioStations.observe(viewLifecycleOwner) { stations -> 83 if (stations.isNotEmpty()) { 84 favoriteRadioStationAdapter.submitList(stations) 85 binding.favoriteStationsRecyclerView.visibility = View.VISIBLE 86 binding.emptyStateView.visibility = View.GONE 87 } else { 88 favoriteRadioStationAdapter.submitList(emptyList()) // Clear list 89 binding.favoriteStationsRecyclerView.visibility = View.GONE 90 binding.emptyStateView.visibility = View.VISIBLE 91 } 92 } 93 94 sharedViewModel.currentPlayingStation.collect { station -> 95 favoriteRadioStationAdapter.setCurrentPlayingStation(station) 96 } 97 } 98 } 99 } 100 101 override fun onDestroyView() { 102 super.onDestroyView() 103 _binding = null 104 } 105}
copy
app/src/main/java/com/example/bloggeradioapp/ui/category/CategoryFragment.kt
copykotlin
1package com.example.bloggeradioapp.ui.category 2 3import android.os.Bundle 4import android.view.LayoutInflater 5import android.view.View 6import android.view.ViewGroup 7import android.widget.Toast 8import androidx.fragment.app.Fragment 9import com.example.bloggeradioapp.R 10import com.example.bloggeradioapp.databinding.FragmentCategoryBinding 11 12class CategoryFragment : Fragment() { 13 14 private var _binding: FragmentCategoryBinding? = null 15 private val binding get() = _binding!! 16 17 override fun onCreateView( 18 inflater: LayoutInflater, container: ViewGroup?, 19 savedInstanceState: Bundle? 20 ): View { 21 _binding = FragmentCategoryBinding.inflate(inflater, container, false) 22 return binding.root 23 } 24 25 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 26 super.onViewCreated(view, savedInstanceState) 27 28 setupToolbar() 29 } 30 31 private fun setupToolbar() { 32 binding.toolbar.setOnMenuItemClickListener { menuItem -> 33 when (menuItem.itemId) { 34 R.id.action_search -> { 35 Toast.makeText(context, "Search clicked", Toast.LENGTH_SHORT).show() 36 true 37 } 38 R.id.action_settings -> { 39 Toast.makeText(context, "Settings clicked", Toast.LENGTH_SHORT).show() 40 true 41 } 42 else -> false 43 } 44 } 45 } 46 47 override fun onDestroyView() { 48 super.onDestroyView() 49 _binding = null 50 } 51}
copy
9. MainActivity
This activity orchestrates the fragments, bottom navigation, and the mini-player.
app/src/main/java/com/example/bloggeradioapp/MainActivity.kt
copykotlin
1package com.example.bloggeradioapp 2 3import android.os.Bundle 4import android.support.v4.media.session.PlaybackStateCompat 5import android.util.Log 6import android.view.View 7import androidx.appcompat.app.AppCompatActivity 8import androidx.fragment.app.FragmentContainerView 9import androidx.lifecycle.Lifecycle 10import androidx.lifecycle.lifecycleScope 11import androidx.lifecycle.repeatOnLifecycle 12import androidx.activity.viewModels 13import androidx.navigation.NavController 14import androidx.navigation.fragment.NavHostFragment 15import androidx.navigation.ui.setupWithNavController 16import com.bumptech.glide.Glide 17import com.example.bloggeradioapp.databinding.ActivityMainBinding 18import com.example.bloggeradioapp.ui.SharedViewModel 19import com.example.bloggeradioapp.ui.SharedViewModelFactory 20import kotlinx.coroutines.launch 21 22class MainActivity : AppCompatActivity() { 23 24 private lateinit var binding: ActivityMainBinding private lateinit var navController: NavController 25 26 // Use activityViewModels() with a custom factory 27 private val sharedViewModel: SharedViewModel by viewModels { SharedViewModelFactory(applicationContext) } 28 29 override fun onCreate(savedInstanceState: Bundle?) { 30 super.onCreate(savedInstanceState) 31 binding = ActivityMainBinding.inflate(layoutInflater) 32 setContentView(binding.root) 33 34 setupNavigation() 35 setupMiniPlayer() 36 observeViewModel() 37 38 sharedViewModel.initMediaBrowser(this) 39 } 40 41 private fun setupNavigation() { 42 val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment 43 navController = navHostFragment.navController 44 binding.bottomNavigation.setupWithNavController(navController) 45 46 // Hide mini-player when on player fragment 47 navController.addOnDestinationChangedListener { _, destination, _ -> 48 when (destination.id) { 49 R.id.playerFragment -> { 50 binding.miniPlayerContainer.visibility = View.GONE 51 binding.bottomNavigation.visibility = View.GONE 52 } 53 else -> { 54 binding.miniPlayerContainer.visibility = View.VISIBLE 55 binding.bottomNavigation.visibility = View.VISIBLE 56 } 57 } 58 } 59 } 60 61 private fun setupMiniPlayer() { 62 binding.miniPlayerLayout.root.setOnClickListener { 63 // Navigate to player fragment when mini-player is clicked 64 if (sharedViewModel.currentPlayingStation.value != null) { 65 navController.navigate(R.id.action_global_playerFragment) 66 } 67 } 68 69 binding.miniPlayerLayout.miniPlayerPlayPause.setOnClickListener { 70 sharedViewModel.togglePlayback() 71 } 72 binding.miniPlayerLayout.miniPlayerRewind.setOnClickListener { 73 // Rewind functionality for mini-player (if applicable, e.g., podcasts) 74 // For live streams, this might do nothing or show a toast 75 Log.d("MainActivity", "Mini-player rewind clicked") 76 } 77 binding.miniPlayerLayout.miniPlayerFastForward.setOnClickListener { 78 // Fast forward functionality for mini-player 79 Log.d("MainActivity", "Mini-player fast forward clicked") 80 } 81 } 82 83 private fun observeViewModel() { 84 lifecycleScope.launch { 85 repeatOnLifecycle(Lifecycle.State.STARTED) { 86 launch { 87 sharedViewModel.currentPlayingStation.collect { station -> 88 updateMiniPlayerInfo(station) 89 } 90 } 91 launch { 92 sharedViewModel.playbackState.collect { state -> 93 updateMiniPlayerPlaybackState(state) 94 } 95 } } 96 } 97 } 98 99 private fun updateMiniPlayerInfo(station: RadioStation?) { 100 if (station == null) { 101 binding.miniPlayerContainer.visibility = View.GONE 102 return 103 } 104 binding.miniPlayerContainer.visibility = View.VISIBLE 105 binding.miniPlayerLayout.miniPlayerTitle.text = station.name 106 binding.miniPlayerLayout.miniPlayerSubtitle.text = station.category ?: "Live Stream" 107 108 // Request focus for marquee effect 109 binding.miniPlayerLayout.miniPlayerTitle.isSelected = true 110 111 Glide.with(this) 112 .load(station.imageUrl) 113 .placeholder(R.drawable.ic_radio_default_art) 114 .error(R.drawable.ic_radio_default_art) 115 .into(binding.miniPlayerLayout.miniPlayerImage) 116 } 117 118 private fun updateMiniPlayerPlaybackState(state: Int) { 119 val playPauseButton = binding.miniPlayerLayout.miniPlayerPlayPause 120 when (state) { 121 PlaybackStateCompat.STATE_PLAYING -> { 122 playPauseButton.setImageResource(R.drawable.ic_pause) 123 } 124 PlaybackStateCompat.STATE_PAUSED -> { 125 playPauseButton.setImageResource(R.drawable.ic_play_arrow) 126 } PlaybackStateCompat.STATE_BUFFERING -> { 127 playPauseButton.setImageResource(R.drawable.ic_hourglass_empty) // Or a loading spinner 128 } 129 else -> { 130 playPauseButton.setImageResource(R.drawable.ic_play_arrow) 131 if (sharedViewModel.currentPlayingStation.value == null) { 132 binding.miniPlayerContainer.visibility = View.GONE 133 } 134 } 135 } } 136 137 override fun onDestroy() { 138 super.onDestroy() 139 sharedViewModel.disconnectMediaBrowser() 140 } 141}
copy
10. AndroidManifest.xml
(Final)
Confirm the manifest is correctly configured with services and permissions.
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:tools="http://schemas.android.com/tools"> 4 5 <uses-permission android:name="android.permission.INTERNET" /> 6 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 7 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- For Android 13+ notifications --> 8 <!-- For Media3 ExoPlayer, if targeting API 34+ and using mediaPlayback type --> 9 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> 10 11 <application 12 android:allowBackup="true" 13 android:dataExtractionRules="@xml/data_extraction_rules" 14 android:fullBackupContent="@xml/backup_rules" 15 android:icon="@mipmap/ic_launcher" 16 android:label="@string/app_name" 17 android:roundIcon="@mipmap/ic_launcher_round" 18 android:supportsRtl="true" 19 android:theme="@style/Theme.BloggerRadioApp" 20 android:usesCleartextTraffic="true" tools:targetApi="34"> <!-- Ensure targetApi is 34 for FOREGROUND_SERVICE_MEDIA_PLAYBACK --> 21 22 <activity 23 android:name=".MainActivity" 24 android:exported="true" 25 android:launchMode="singleTop"> <!-- Use singleTop to prevent multiple instances when launching from notification --> 26 <intent-filter> 27 <action android:name="android.intent.action.MAIN" /> 28 <category android:name="android.intent.category.LAUNCHER" /> 29 </intent-filter> 30 </activity> 31 32 <service 33 android:name=".playback.PlaybackService" 34 android:exported="true" 35 android:foregroundServiceType="mediaPlayback"> 36 <intent-filter> 37 <action android:name="android.media.browse.MediaBrowserService" /> 38 </intent-filter> 39 </service> 40 41 </application> 42 43</manifest>
copy
11. Helper Classes & Resources
app/src/main/res/navigation/nav_graph.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<navigation xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:app="http://schemas.android.com/apk/res-auto" 4 xmlns:tools="http://schemas.android.com/tools" 5 android:id="@+id/nav_graph" 6 app:startDestination="@id/homeFragment"> 7 8 <fragment 9 android:id="@+id/homeFragment" 10 android:name="com.example.bloggeradioapp.ui.home.HomeFragment" 11 android:label="Blogger Radio App" 12 tools:layout="@layout/fragment_home" /> <fragment 13 android:id="@+id/categoryFragment" 14 android:name="com.example.bloggeradioapp.ui.category.CategoryFragment" 15 android:label="Category" 16 tools:layout="@layout/fragment_category" /> 17 <fragment 18 android:id="@+id/favoriteFragment" 19 android:name="com.example.bloggeradioapp.ui.favorite.FavoriteFragment" 20 android:label="Favorite" 21 tools:layout="@layout/fragment_favorite" /> 22 <fragment 23 android:id="@+id/playerFragment" 24 android:name="com.example.bloggeradioapp.ui.player.PlayerFragment" 25 android:label="Player" 26 tools:layout="@layout/fragment_player" /> 27 28 <!-- Global action to navigate to PlayerFragment from anywhere --> 29 <action 30 android:id="@+id/action_global_playerFragment" 31 app:destination="@id/playerFragment" /> 32 33</navigation>
copy
app/src/main/res/menu/bottom_nav_menu.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<menu xmlns:android="http://schemas.android.com/apk/res/android"> 3 <item 4 android:id="@+id/homeFragment" 5 android:icon="@drawable/ic_home" 6 android:title="@string/home_title" /> 7 <item 8 android:id="@+id/categoryFragment" 9 android:icon="@drawable/ic_category" 10 android:title="@string/category_title" /> 11 <item 12 android:id="@+id/favoriteFragment" 13 android:icon="@drawable/ic_heart_filled" 14 android:title="@string/favorite_title" /> 15</menu>
app/src/main/res/menu/toolbar_menu.xml
(for Home/Category/Favorite fragments)
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<menu xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:app="http://schemas.android.com/apk/res-auto"> 4 <item 5 android:id="@+id/action_search" 6 android:icon="@drawable/ic_search" 7 android:title="@string/search" 8 app:showAsAction="ifRoom" /> 9 <item 10 android:id="@+id/action_settings" 11 android:icon="@drawable/ic_settings" 12 android:title="@string/settings" 13 app:showAsAction="ifRoom" /> 14</menu>
app/src/main/res/menu/player_toolbar_menu.xml
(for PlayerFragment)
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<menu xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:app="http://schemas.android.com/apk/res-auto"> 4 <item 5 android:id="@+id/action_favorite" 6 android:icon="@drawable/ic_heart_outline" 7 android:title="@string/favorite_title" 8 app:showAsAction="ifRoom" /> 9 <item 10 android:id="@+id/action_info" 11 android:icon="@drawable/ic_info" 12 android:title="@string/info" 13 app:showAsAction="ifRoom" /> 14 <item 15 android:id="@+id/action_share" 16 android:icon="@drawable/ic_share" 17 android:title="@string/share" 18 app:showAsAction="ifRoom" /> 19</menu>
copy
app/src/main/res/values/strings.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<resources> 3 <string name="app_name">Blogger Radio App</string> 4 <string name="home_title">Home</string> 5 <string name="category_title">Category</string> 6 <string name="favorite_title">Favorite</string> 7 <string name="search">Search</string> 8 <string name="settings">Settings</string> 9 <string name="more_options_description">More options</string> 10 <string name="rewind_button_description">Rewind</string> 11 <string name="play_pause_button_description">Play/Pause</string> 12 <string name="fast_forward_button_description">Fast Forward</string> 13 <string name="timer_button_description">Sleep Timer</string> 14 <string name="volume_button_description">Volume</string> 15 <string name="info">Info</string> 16 <string name="share">Share</string> 17 <string name="notification_channel_name">Radio Playback</string> 18 <string name="notification_channel_description">Notifications for radio playback.</string> 19 <string name="no_station_playing">No Station Playing</string> 20 <string name="empty_favorite_icon_description">Empty favorite list icon</string> 21 <string name="whoops_title">Whoops!</string> 22 <string name="empty_favorite_message">Your favorite list is empty because you do not add any videos in the favorite menu.</string> 23 <string name="misc_category">Misc</string> 24 <string name="category_fragment_placeholder">Categories will be listed here.</string> 25 <string name="error_loading_stations">Failed to load radio stations. Please check your internet connection or blog posts.</string> 26 <string name="no_stations_found">No radio stations found. Please add posts to your blog following the specified format.</string> 27</resources>
copy
app/src/main/res/values/themes.xml
copyxml
1<?xml version="1.0" encoding="utf-8"?> 2<resources> 3 <style name="Theme.BloggerRadioApp" parent="Theme.MaterialComponents.DayNight.NoActionBar"> 4 <!-- Primary brand color. --> 5 <item name="colorPrimary">@color/purple_500</item> 6 <item name="colorPrimaryVariant">@color/purple_700</item> 7 <item name="colorOnPrimary">@color/white</item> 8 <!-- Secondary brand color. --> 9 <item name="colorSecondary">@color/teal_200</item> 10 <item name="colorSecondaryVariant">@color/teal_700</item> 11 <item name="colorOnSecondary">@color/black</item> 12 <!-- Status bar color. --> 13 <item name="android:statusBarColor">?attr/colorPrimaryVariant</item> 14 <!-- Customize your theme here. --> 15 16 <!-- Custom colors for UI elements --> 17 <item name="colorAccent">@color/pink_accent</item> <!-- For FAB, etc. --> 18 <item name="colorOnAccent">@color/white</item> <!-- Background colors --> 19 <item name="android:windowBackground">@color/white</item> 20 <item name="colorSurface">@color/white</item> <!-- For mini-player, cards --> 21 <item name="colorOnSurface">@color/black</item> 22 <item name="colorOnSurfaceVariant">@color/grey_600</item> 23 24 <!-- Player screen background --> 25 <item name="colorPrimaryDark">@color/dark_blue_background</item> 26 <item name="colorOnPrimaryDark">@color/white</item> 27 <item name="colorOnPrimaryVariant">@color/light_grey</item> </style> 28 29 <!-- Shape appearance for circular images (e.g., station logos) --> 30 <style name="ShapeAppearanceOverlay.App.CornerSize50Percent" parent=""> 31 <item name="cornerFamily">rounded</item> 32 <item name="cornerSize">50%</item> 33 </style> 34 35 <!-- Shape appearance for slightly rounded square images (e.g., player art) --> 36 <style name="ShapeAppearanceOverlay.App.CornerSize8dp" parent=""> 37 <item name="cornerFamily">rounded</item> 38 <item name="cornerSize">8dp</item> 39 </style> 40</resources>``` 41 42**`app/src/main/res/values/colors.xml`** 43 44```xml 45<?xml version="1.0" encoding="utf-8"?> 46<resources> 47 <color name="purple_200">#FFBB86FC</color> 48 <color name="purple_500">#FF6200EE</color> 49 <color name="purple_700">#FF3700B3</color> 50 <color name="teal_200">#FF03DAC5</color> 51 <color name="teal_700">#FF018786</color> 52 <color name="black">#FF000000</color> 53 <color name="white">#FFFFFFFF</color> 54 55 <!-- Custom colors based on the design images --> 56 <color name="pink_accent">#FFEC407A</color> <!-- For Play/Pause FAB --> 57 <color name="dark_blue_background">#FF282C3F</color> <!-- For PlayerFragment background --> 58 <color name="light_grey">#FFCCCCCC</color> 59 <color name="grey_600">#FF757575</color> 60 61 <!-- Bottom Nav Colors --> 62 <color name="bottom_nav_icon_selected">@color/purple_500</color> 63 <color name="bottom_nav_icon_unselected">@color/grey_600</color> 64 65 <!-- Selector for bottom navigation item colors --> 66 <selector xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/bottom_nav_item_color"> 67 <item android:color="@color/bottom_nav_icon_selected" android:state_checked="true"/> 68 <item android:color="@color/bottom_nav_icon_unselected" android:state_checked="false"/> 69 </selector> 70 71 <!-- Playing item background color for RecyclerView --> 72 <color name="playing_item_background">#E0E0E0</color> <!-- Light grey to indicate playing --> 73</resources>``` 74 75**Drawables (you need to add these to `app/src/main/res/drawable/`):** 76 77* `ic_home.xml` (Home icon for Bottom Nav) 78* `ic_category.xml` (Category icon for Bottom Nav) 79* `ic_heart_filled.xml` (Filled heart for Favorite, and selected Bottom Nav) 80* `ic_heart_outline.xml` (Outline heart for Favorite empty state, and unselected Player toolbar) 81* `ic_search.xml` (Search icon) 82* `ic_settings.xml` (Settings icon) 83* `ic_more_vert.xml` (Three dots vertical for list item) 84* `ic_skip_previous.xml` (Rewind icon) 85* `ic_skip_next.xml` (Fast Forward icon) 86* `ic_play_arrow.xml` (Play icon for mini-player) 87* `ic_pause.xml` (Pause icon for mini-player) 88* `ic_play_arrow_filled.xml` (Filled play icon for Player FAB) 89* `ic_pause_filled.xml` (Filled pause icon for Player FAB) 90* `ic_arrow_down.xml` (Down arrow for Player toolbar navigation) 91* `ic_info.xml` (Info icon) 92* `ic_share.xml` (Share icon) 93* `ic_timer.xml` (Timer icon) 94* `ic_volume_up.xml` (Volume up icon) 95* `ic_hourglass_empty.xml` (Hourglass for buffering) 96* `ic_radio_default_art.xml` (A default image for radio stations, e.g., a microphone, as a placeholder/error image.) 97 98You can get these icons from Material Design Icons (e.g., using Android Studio's Vector Asset Studio). 99 100--- 101 102### **How to Run and Use the App:** 103 1041. **Create a New Android Studio Project:** Select "Empty Views Activity" and use Kotlin. 1052. **Update `build.gradle`:** Copy the `dependencies` and `android` block into your `app/build.gradle` file. Sync the project. 1063. **Copy Kotlin Files:** Place all `.kt` files into their respective package paths (`com.example.bloggeradioapp.data.model`, `com.example.bloggeradioapp.data.source`, etc.). 1074. **Copy XML Layouts:** Place all `.xml` files into `app/src/main/res/layout/`. 1085. **Copy XML Menus:** Place all menu `.xml` files into `app/src/main/res/menu/`. 1096. **Copy XML Navigation:** Place `nav_graph.xml` into `app/src/main/res/navigation/`. 1107. **Copy XML Values:** Place `strings.xml`, `themes.xml`, `colors.xml` into `app/src/main/res/values/`. 1118. **Add Drawables:** Create the `app/src/main/res/drawable/` folder and add the required icon XML files (e.g., `ic_home.xml`, `ic_radio_default_art.xml`, etc.). Use Android Studio's "New" -> "Vector Asset" to easily add Material Icons. 1129. **Update `AndroidManifest.xml`:** Copy the final `AndroidManifest.xml` content. 11310. **Crucial Blogger Configuration:** 114 * Open `app/src/main/java/com/example/bloggeradioapp/ui/SharedViewModelFactory.kt`. 115 * **Change `val bloggerFeedUrl = "https://seebatorefmtamil.blogspot.com/feeds/posts/default"` to your exact blog's RSS feed URL if it's different.** The provided one is the standard for Blogger. 11611. **Prepare Your Blogger Blog:** 117 * Go to `https://seebatorefmtamil.blogspot.com` (or your blog). 118 * Create **new posts** for each radio station. 119 * **Title:** The station's name. 120 * **Labels:** Add one label for the category (e.g., "Culture", "World"). The app will use the first one. 121 * **Content (HTML View):** In the post editor, switch to HTML view (`< >` icon). 122 * Include the **direct stream URL** (e.g., `http://example.com/mystream.mp3`). The app looks for `http(s)://` URLs ending in common audio formats. 123 * Include an **image tag** pointing to the station's logo (e.g., `<img src="http://example.com/logo.png"/>`). The app picks the first `<img>`'s `src`. 124 * Example post body in HTML view: 125 ```html 126 <p>This is a great radio station!</p> 127 <p>Stream live here: <a href="http://stream.example.com/radio.mp3">Radio Stream</a></p> 128 <img src="http://image.example.com/radio_logo.png" alt="Radio Logo" width="200" height="200"/> 129 <p>Enjoy the music.</p> 130 ``` 131 * **Publish** these posts. 132 13312. **Run the App:** Build and run the application on an Android device or emulator. It should fetch stations from your blog, display them, and allow playback. 134 135This comprehensive solution provides a strong foundation for your online FM radio app, incorporating best practices for media playback, UI design, and data management, while integrating with your Blogger content.
copy