baseline before import-songs feature

This commit is contained in:
dev 2026-09-07 11:04:09 -05:00
commit 72e55ba319
2335 changed files with 59929 additions and 0 deletions

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".RadAmpApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.RadAmp">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.RadAmp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".player.PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package com.rada
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class RadAmpApp : Application()

View file

@ -0,0 +1,382 @@
package com.rada.data.local
import com.rada.domain.model.Station
object DefaultStations {
private val builtIn = listOf(
// ── Lofi ──
Station(
id = "fluxfm-chillhop",
name = "FLUX FM ChillHop",
url = "http://streams.fluxfm.de/Chillhop/mp3-320",
genre = "Lofi / Chillhop",
description = "Chill beats and lofi hip-hop from FLUX FM Berlin",
country = "DE",
),
Station(
id = "lofi-24-7",
name = "bigFM LoFi Focus",
url = "https://stream.bigfm.de/lofifocus/mp3-128/radiobrowser",
genre = "Lofi",
description = "Lo-fi beats to focus and study — from bigFM",
country = "DE",
),
Station(
id = "chillofi-radio",
name = "Chillofi Radio",
url = "https://azc.rdstream-5677.dez.ovh/listen/chillofi/radio.mp3",
genre = "Lofi / Chillhop",
description = "Relax and study beats from France",
country = "FR",
),
Station(
id = "reyfm-lofi",
name = "REYFM #LOFI",
url = "https://listen.reyfm.de/lofi_320kbps.mp3",
genre = "Lofi",
description = "German lofi radio — chill vibes at 320kbps",
country = "DE",
),
// ── Synthwave ──
Station(
id = "synthwave-city-fm",
name = "Synthwave City FM",
url = "https://synthwave-rex.radioca.st/stream",
genre = "Synthwave",
description = "Retro synth, outrun, and neon-soaked beats",
country = "NL",
),
Station(
id = "synthwave-radio",
name = "Synthwave Radio",
url = "https://stream.synthwaveradio.eu/listen/synthwaveradio.eu/radio.mp3",
genre = "Synthwave",
description = "Synthwave, retrowave, and outrun",
country = "US",
),
Station(
id = "synthwave-neon-city",
name = "Synthwave Neon City",
url = "https://stream.laut.fm/synthwave",
genre = "Synthwave",
description = "Neon-drenched synthwave from laut.fm",
country = "DE",
),
// ── EDM ──
Station(
id = "electrodanceworld",
name = "PulseEDM",
url = "http://pulseedm.cdnstream1.com:8124/1373_128",
genre = "EDM / Dance",
description = "EDM, house, trance, dubstep — PulseEDM Radio",
country = "US",
),
Station(
id = "technolovers-edm",
name = "Technolovers EDM",
url = "https://stream.technolovers.fm/edm",
genre = "EDM",
description = "Club house, electro, and electronic dance music",
country = "DE",
),
Station(
id = "generation-dance",
name = "Generation Dance Radio",
url = "http://generationdance.lu/radio/8030/hd",
genre = "EDM / Dance",
description = "Eurodance, hands up, house, and trance",
country = "LU",
),
// ── Phonk ──
Station(
id = "phonkio-radio",
name = "Phonk Radio",
url = "https://stream.phonkioradio.com/listen/phonkio_radio/radio.mp3",
genre = "Phonk",
description = "24/7 phonk — drift, Memphis, and cowbells",
country = "US",
),
Station(
id = "badradio-phonk",
name = "badradio 24/7",
url = "https://s2.radio.co/s2b2b68744/listen",
genre = "Phonk / Memphis",
description = "OG phonk, Memphis, Houston, trap & trill",
country = "NZ",
),
Station(
id = "dopephonk",
name = "DOPEPHONK",
url = "https://listen.atomic.radio/dopephonk/middlequality",
genre = "Phonk",
description = "Pure phonk vibes 24/7",
country = "US",
),
// ── Classics / Classical ──
Station(
id = "classic-fm-uk",
name = "CFMZ Classical FM",
url = "https://live.amperwave.net/direct/mzmedia-cfmzfmmp3-ibc2",
genre = "Classical",
description = "Classical FM from Canada — baroque to romantic",
country = "CA",
),
Station(
id = "your-classical-relax",
name = "Your Classical - Relax",
url = "http://relax.stream.publicradio.org/relax.mp3",
genre = "Classical / Relax",
description = "Peaceful classical music from American Public Media",
country = "US",
),
Station(
id = "classical-piano",
name = "Peaceful Piano",
url = "https://holiday.stream.publicradio.org/peacefulpiano.mp3",
genre = "Classical Piano",
description = "Peaceful classical piano — Chopin, Debussy, Einaudi",
country = "US",
),
Station(
id = "classical-guitar-181",
name = "181.FM - Classical Guitar",
url = "https://listen.181fm.com/181-classicalguitar_128k.mp3",
genre = "Classical Guitar",
description = "Classical and Spanish guitar from 181.FM",
country = "US",
),
Station(
id = "epic-classical-piano",
name = "EPIC CLASSICAL - Piano",
url = "https://stream.epic-classical.com/classical-piano",
genre = "Classical Piano",
description = "Ambient, chill, and relaxing classical piano",
country = "DE",
),
// ── Strings / Ambient ──
Station(
id = "soma-fm-lush",
name = "Lush",
url = "https://ice1.somafm.com/lush-128-mp3",
genre = "Strings / Ambient",
description = "Sensuous and mellow vocals with lush string textures",
country = "US",
),
Station(
id = "soma-fm-dronezone",
name = "Drone Zone",
url = "https://ice1.somafm.com/dronezone-128-mp3",
genre = "Ambient / Strings",
description = "Atmospheric textures with beats and samples",
country = "US",
),
// ── Electronic / NCS ──
Station(
id = "ncs-radio",
name = "NCS Radio",
url = "https://radio.cliamp.stream/ncs/stream",
genre = "Electronic / NCS",
description = "NoCopyrightSounds — unlimited gaming music",
country = "US",
),
// ── cliamp.stream ──
Station(
id = "cliamp-lofi",
name = "Lofi Hip Hop Radio",
url = "https://radio.cliamp.stream/lofi/stream",
genre = "Lofi / Hip-Hop",
description = "24/7 lofi hip-hop beats to chill and code to — from cliamp.stream",
country = "US",
),
Station(
id = "cliamp-edm",
name = "EDM Pulse",
url = "https://radio.cliamp.stream/edm/stream",
genre = "EDM / Dance",
description = "Non-stop EDM pulse — from cliamp.stream",
country = "US",
),
Station(
id = "cliamp-synthwave",
name = "Synthwave Nights",
url = "https://radio.cliamp.stream/synthwave/stream",
genre = "Synthwave",
description = "Retro synthwave nights — neon drives at midnight, from cliamp.stream",
country = "US",
),
Station(
id = "cliamp-omarchy",
name = "Omarchy Radio",
url = "https://radio.cliamp.stream/omarchy/stream",
genre = "Electronic / Community",
description = "The Omarchy Linux community radio station — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-house",
name = "NCS House",
url = "https://radio.cliamp.stream/ncs-house/stream",
genre = "Electronic / House",
description = "NoCopyrightSounds house mixes — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-dubstep",
name = "NCS Dubstep",
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
genre = "Electronic / Dubstep",
description = "NoCopyrightSounds dubstep — heavy bass drops, from cliamp.stream",
country = "US",
),
Station(
id = "ncs-dnb",
name = "NCS Drum & Bass",
url = "https://radio.cliamp.stream/ncs-dnb/stream",
genre = "Electronic / Drum & Bass",
description = "NoCopyrightSounds drum & bass — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-trap",
name = "NCS Trap",
url = "https://radio.cliamp.stream/ncs-trap/stream",
genre = "Electronic / Trap",
description = "NoCopyrightSounds trap beats — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-phonk",
name = "NCS Phonk",
url = "https://radio.cliamp.stream/ncs-phonk/stream",
genre = "Phonk",
description = "NoCopyrightSounds phonk — drift phonk and cowbells, from cliamp.stream",
country = "US",
),
Station(
id = "ncs-pop",
name = "NCS Pop",
url = "https://radio.cliamp.stream/ncs-pop/stream",
genre = "Electronic / Pop",
description = "NoCopyrightSounds pop hits — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-chill",
name = "NCS Chill",
url = "https://radio.cliamp.stream/ncs-chill/stream",
genre = "Electronic / Chill",
description = "NoCopyrightSounds chill mixes — from cliamp.stream",
country = "US",
),
// ── SomaFM classics ──
Station(
id = "soma-fm-groovesalad",
name = "Groove Salad",
url = "https://ice1.somafm.com/groovesalad-128-mp3",
genre = "Ambient / Downtempo",
description = "A nicely chilled plate of ambient beats and grooves",
country = "US",
),
Station(
id = "soma-fm-spacestation",
name = "Space Station Soma",
url = "https://ice1.somafm.com/spacestation-128-mp3",
genre = "Ambient / Electronica",
description = "Tune in, turn on, drift away",
country = "US",
),
Station(
id = "soma-fm-defcon",
name = "DEF CON",
url = "https://ice1.somafm.com/defcon-128-mp3",
genre = "Electronic / Hacker",
description = "The DEF CON hacker convention radio",
country = "US",
),
Station(
id = "soma-fm-thetrip",
name = "The Trip",
url = "https://ice1.somafm.com/thetrip-128-mp3",
genre = "House / Techno",
description = "Progressive house / trance. Tip top tunes.",
country = "US",
),
Station(
id = "soma-fm-fluid",
name = "Fluid",
url = "https://ice1.somafm.com/fluid-128-mp3",
genre = "Instrumental Hip-Hop",
description = "Instrumental hip-hop beats and grooves",
country = "US",
),
Station(
id = "soma-fm-seventies",
name = "Left Coast 70s",
url = "https://ice1.somafm.com/seventies-128-mp3",
genre = "Retro / 70s",
description = "Mellow grooves from the 1970s",
country = "US",
),
// ── Classic Rock ──
Station(
id = "rdmix-classic-rock",
name = "RdMix Classic Rock",
url = "https://cast1.torontocast.com:4610/stream",
genre = "Classic Rock",
description = "Classic rock from the 70s, 80s, and 90s",
country = "CA",
),
// ── Oldies ──
Station(
id = "good-time-oldies",
name = "Good Time Oldies",
url = "http://listen.181fm.com/181-goodtime_128k.mp3",
genre = "Oldies",
description = "The greatest oldies from the 50s and 60s",
country = "US",
),
// ── Jazz ──
Station(
id = "smooth-jazz-101",
name = "101 Smooth Jazz",
url = "http://jking.cdnstream1.com/b22139_128mp3",
genre = "Jazz / Smooth Jazz",
description = "Smooth jazz — saxophone, keys, and chill grooves",
country = "US",
),
// ── Eclectic / Funk / Hip-Hop ──
Station(
id = "kcrw-eclectic",
name = "KCRW Eclectic 24",
url = "https://streams.kcrw.com/e24_mp3",
genre = "Eclectic / Funk / Hip-Hop / Jazz",
description = "Eclectic mix — funk, hip-hop, jazz, alternative, experimental",
country = "US",
),
Station(
id = "radio-paradise-eclectic",
name = "Radio Paradise Eclectic",
url = "http://stream.radioparadise.com/eclectic-320",
genre = "Eclectic / World / Rock / Funk",
description = "Eclectic world mix — rock, funk, world, and everything in between",
country = "US",
),
)
fun getAll(): List<Station> = builtIn
fun getById(id: String): Station? = builtIn.find { it.id == id }
}

View file

@ -0,0 +1,61 @@
package com.rada.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rada.data.local.dao.PlaylistDao
import com.rada.data.local.dao.SavedSongDao
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.PlaylistSongEntity
import com.rada.data.local.entity.SavedSongEntity
@Database(
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
version = 2,
exportSchema = false,
)
abstract class RadAmpDatabase : RoomDatabase() {
abstract fun savedSongDao(): SavedSongDao
abstract fun playlistDao(): PlaylistDao
companion object {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL
)
"""
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS playlist_songs (
playlistId INTEGER NOT NULL,
songId INTEGER NOT NULL,
addedAt INTEGER NOT NULL,
PRIMARY KEY(playlistId, songId),
FOREIGN KEY(playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY(songId) REFERENCES saved_songs(id) ON DELETE CASCADE
)
"""
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_songId ON playlist_songs(songId)")
}
}
fun create(context: Context): RadAmpDatabase {
return Room.databaseBuilder(
context.applicationContext,
RadAmpDatabase::class.java,
"radamp.db",
).addMigrations(MIGRATION_1_2).build()
}
}
}

View file

@ -0,0 +1,54 @@
package com.rada.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.PlaylistSongEntity
import com.rada.data.local.entity.SavedSongEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PlaylistDao {
@Query("SELECT * FROM playlists ORDER BY createdAt DESC")
fun getAllPlaylists(): Flow<List<PlaylistEntity>>
@Query("SELECT * FROM playlists WHERE id = :playlistId")
suspend fun getPlaylistById(playlistId: Long): PlaylistEntity?
@Insert
suspend fun createPlaylist(playlist: PlaylistEntity): Long
@Delete
suspend fun deletePlaylist(playlist: PlaylistEntity)
@Query("DELETE FROM playlists WHERE id = :playlistId")
suspend fun deletePlaylistById(playlistId: Long)
@Insert
suspend fun addSongToPlaylist(playlistSong: PlaylistSongEntity)
@Query("DELETE FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId")
suspend fun removeSongFromPlaylist(playlistId: Long, songId: Long)
@Query(
"""
SELECT s.* FROM saved_songs s
INNER JOIN playlist_songs ps ON s.id = ps.songId
WHERE ps.playlistId = :playlistId
ORDER BY ps.addedAt DESC
"""
)
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>>
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
fun getPlaylistSongCount(playlistId: Long): Flow<Int>
@Query("SELECT EXISTS(SELECT 1 FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId)")
suspend fun isSongInPlaylist(playlistId: Long, songId: Long): Boolean
@Query("SELECT ps.playlistId FROM playlist_songs ps WHERE ps.songId = :songId")
suspend fun getPlaylistIdsForSong(songId: Long): List<Long>
}

View file

@ -0,0 +1,52 @@
package com.rada.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.rada.data.local.entity.SavedSongEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SavedSongDao {
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
fun getAllByDate(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
fun getAllByArtist(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs ORDER BY title ASC")
fun getAllByTitle(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs WHERE isFavorite = 1 ORDER BY dateSaved DESC")
fun getFavorites(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs WHERE artist LIKE '%' || :query || '%' OR title LIKE '%' || :query || '%' OR stationName LIKE '%' || :query || '%'")
fun search(query: String): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs WHERE id = :id")
suspend fun getById(id: Long): SavedSongEntity?
@Query("SELECT * FROM saved_songs WHERE filePath = :path LIMIT 1")
suspend fun getByPath(path: String): SavedSongEntity?
@Insert
suspend fun insert(song: SavedSongEntity): Long
@Update
suspend fun update(song: SavedSongEntity)
@Delete
suspend fun delete(song: SavedSongEntity)
@Query("DELETE FROM saved_songs WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT COUNT(*) FROM saved_songs")
fun getCount(): Flow<Int>
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC LIMIT :limit")
fun getRecentlySaved(limit: Int = 20): Flow<List<SavedSongEntity>>
}

View file

@ -0,0 +1,12 @@
package com.rada.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "playlists")
data class PlaylistEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val createdAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,32 @@
package com.rada.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "playlist_songs",
primaryKeys = ["playlistId", "songId"],
foreignKeys = [
ForeignKey(
entity = PlaylistEntity::class,
parentColumns = ["id"],
childColumns = ["playlistId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = SavedSongEntity::class,
parentColumns = ["id"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index("songId"),
],
)
data class PlaylistSongEntity(
val playlistId: Long,
val songId: Long,
val addedAt: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,21 @@
package com.rada.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "saved_songs")
data class SavedSongEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val fileName: String,
val filePath: String,
val artist: String,
val title: String,
val album: String,
val stationName: String,
val durationMs: Long = 0,
val fileSize: Long = 0,
val format: String = "mp3",
val dateSaved: Long = System.currentTimeMillis(),
val isFavorite: Boolean = false,
)

View file

@ -0,0 +1,53 @@
package com.rada.data.repository
import com.rada.data.local.dao.SavedSongDao
import com.rada.data.local.entity.SavedSongEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
enum class SortOrder {
DATE_SAVED,
ARTIST,
TITLE,
}
@Singleton
class LibraryRepository @Inject constructor(
private val savedSongDao: SavedSongDao,
) {
fun getAll(sortOrder: SortOrder = SortOrder.DATE_SAVED): Flow<List<SavedSongEntity>> {
return when (sortOrder) {
SortOrder.DATE_SAVED -> savedSongDao.getAllByDate()
SortOrder.ARTIST -> savedSongDao.getAllByArtist()
SortOrder.TITLE -> savedSongDao.getAllByTitle()
}
}
fun getFavorites(): Flow<List<SavedSongEntity>> = savedSongDao.getFavorites()
fun search(query: String): Flow<List<SavedSongEntity>> = savedSongDao.search(query)
fun getRecentlySaved(limit: Int = 20): Flow<List<SavedSongEntity>> =
savedSongDao.getRecentlySaved(limit)
fun getCount(): Flow<Int> = savedSongDao.getCount()
suspend fun getById(id: Long): SavedSongEntity? = savedSongDao.getById(id)
suspend fun getByPath(path: String): SavedSongEntity? = savedSongDao.getByPath(path)
suspend fun insert(song: SavedSongEntity): Long = savedSongDao.insert(song)
suspend fun update(song: SavedSongEntity) = savedSongDao.update(song)
suspend fun toggleFavorite(id: Long) {
val song = savedSongDao.getById(id) ?: return
savedSongDao.update(song.copy(isFavorite = !song.isFavorite))
}
suspend fun delete(id: Long) = savedSongDao.deleteById(id)
suspend fun delete(song: SavedSongEntity) = savedSongDao.delete(song)
}

View file

@ -0,0 +1,53 @@
package com.rada.data.repository
import com.rada.data.local.dao.PlaylistDao
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.PlaylistSongEntity
import com.rada.data.local.entity.SavedSongEntity
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PlaylistRepository @Inject constructor(
private val playlistDao: PlaylistDao,
) {
fun getAllPlaylists(): Flow<List<PlaylistEntity>> = playlistDao.getAllPlaylists()
suspend fun getPlaylistById(id: Long): PlaylistEntity? = playlistDao.getPlaylistById(id)
suspend fun createPlaylist(name: String): Long {
return playlistDao.createPlaylist(PlaylistEntity(name = name))
}
suspend fun deletePlaylist(playlist: PlaylistEntity) = playlistDao.deletePlaylist(playlist)
suspend fun deletePlaylistById(id: Long) = playlistDao.deletePlaylistById(id)
suspend fun addSongToPlaylist(playlistId: Long, songId: Long) {
playlistDao.addSongToPlaylist(
PlaylistSongEntity(playlistId = playlistId, songId = songId)
)
}
suspend fun removeSongFromPlaylist(playlistId: Long, songId: Long) {
playlistDao.removeSongFromPlaylist(playlistId, songId)
}
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>> {
return playlistDao.getSongsInPlaylist(playlistId)
}
fun getPlaylistSongCount(playlistId: Long): Flow<Int> {
return playlistDao.getPlaylistSongCount(playlistId)
}
suspend fun isSongInPlaylist(playlistId: Long, songId: Long): Boolean {
return playlistDao.isSongInPlaylist(playlistId, songId)
}
suspend fun getPlaylistIdsForSong(songId: Long): List<Long> {
return playlistDao.getPlaylistIdsForSong(songId)
}
}

View file

@ -0,0 +1,131 @@
package com.rada.data.repository
import android.content.Context
import android.content.SharedPreferences
import com.rada.data.local.DefaultStations
import com.rada.domain.model.Station
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StationRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("rada_stations", Context.MODE_PRIVATE)
private val stations = mutableListOf<Station>()
private val nameOverrides = mutableMapOf<String, String>()
init {
nameOverrides.putAll(loadNameOverrides())
rebuild()
}
fun getAll(): List<Station> = stations.toList()
fun getById(id: String): Station? = stations.find { it.id == id }
fun addStation(station: Station) {
stations.add(station)
saveCustomStations()
}
fun removeStation(id: String) {
stations.removeAll { it.id == id }
if (nameOverrides.remove(id) != null) saveNameOverrides()
saveCustomStations()
}
fun isCustomStation(id: String): Boolean {
return stations.any { it.id == id } && !DefaultStations.getAll().any { it.id == id }
}
fun renameStation(id: String, newName: String) {
val trimmed = newName.trim()
if (trimmed.isEmpty() || stations.none { it.id == id }) return
nameOverrides[id] = trimmed
rebuild()
saveNameOverrides()
}
fun resetStationName(id: String) {
if (nameOverrides.remove(id) == null) return
rebuild()
saveNameOverrides()
}
fun isRenamed(id: String): Boolean = nameOverrides.containsKey(id)
fun getOriginalStationName(id: String): String? =
DefaultStations.getById(id)?.name ?: loadCustomStations().find { it.id == id }?.name
private fun rebuild() {
stations.clear()
stations.addAll(DefaultStations.getAll())
stations.addAll(loadCustomStations())
for (i in stations.indices) {
val station = stations[i]
nameOverrides[station.id]?.let { override ->
stations[i] = station.copy(name = override)
}
}
}
private fun loadNameOverrides(): Map<String, String> {
val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
return try {
val obj = JSONObject(json)
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
} catch (_: Exception) {
emptyMap()
}
}
private fun saveNameOverrides() {
val obj = JSONObject()
nameOverrides.forEach { (id, name) -> obj.put(id, name) }
prefs.edit().putString(KEY_NAME_OVERRIDES, obj.toString()).apply()
}
private fun loadCustomStations(): List<Station> {
val json = prefs.getString(KEY_CUSTOM_STATIONS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val obj = arr.getJSONObject(i)
Station(
id = obj.getString("id"),
name = obj.getString("name"),
url = obj.getString("url"),
genre = obj.optString("genre", "Custom"),
)
}
} catch (_: Exception) {
emptyList()
}
}
private fun saveCustomStations() {
val custom = stations.filter { isCustomStation(it.id) }
val arr = JSONArray()
custom.forEach { s ->
arr.put(JSONObject().apply {
put("id", s.id)
put("name", s.name)
put("url", s.url)
put("genre", s.genre)
})
}
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
}
companion object {
private const val KEY_CUSTOM_STATIONS = "custom_stations"
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
}
}

View file

@ -0,0 +1,147 @@
package com.rada.data.repository
import com.rada.domain.model.StreamEntry
import java.net.URLDecoder
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Turns a user-supplied URL into [StreamEntry]s.
*
* - A playlist (m3u / m3u8 / pls) is fetched over HTTP and expanded into
* its contained stream entries.
* - Anything else is treated as a direct stream URL.
*/
@Singleton
class StreamFetcher @Inject constructor() {
private val client = OkHttpClient()
suspend fun fetch(userUrl: String): List<StreamEntry> = withContext(Dispatchers.IO) {
val trimmed = userUrl.trim()
if (trimmed.isBlank()) return@withContext emptyList()
val normalized = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) trimmed
else "http://$trimmed"
if (classify(normalized) == "playlist") {
val body = download(normalized)
parsePlaylist(body, normalized)
} else {
listOf(directEntry(normalized))
}
}
private fun classify(url: String): String {
val path = try {
java.net.URI(url).path?.lowercase(Locale.ROOT).orEmpty()
} catch (_: Exception) {
url.substringAfter("://").substringAfter("/").substringBefore("?").lowercase(Locale.ROOT)
}
return when {
path.endsWith(".m3u") || path.endsWith(".m3u8") || path.endsWith(".pls") -> "playlist"
else -> "stream"
}
}
private fun download(url: String): String {
val request = Request.Builder().url(url).header("User-Agent", "RadAmp/1.0").build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch playlist: ${response.code}")
return response.body?.string().orEmpty()
}
}
private fun parsePlaylist(body: String, sourceUrl: String): List<StreamEntry> {
val fileName = sourceUrl.substringAfterLast("/").substringBefore("?")
.removeSuffix(".m3u8").removeSuffix(".m3u").removeSuffix(".pls")
val host = sourceUrl.substringAfter("://").substringBefore("/")
val sourceName = fileName.ifEmpty { host }
val lines = body.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
val entries = mutableListOf<StreamEntry>()
var pendingName: String? = null
for (line in lines) {
when {
line.startsWith("#EXTINF:") -> {
// #EXTINF:-1,Artist - Title
val comma = line.indexOf(',')
pendingName = if (comma >= 0) line.substring(comma + 1).trim() else null
}
line.startsWith("#EXTM3U") || line.startsWith("#EXT") -> {
// other directives (EXTM3U, EXTGRP, EXTMCAT...) - ignore
}
line.startsWith("#") -> {
// unknown comment - ignore
}
line.startsWith("http://") || line.startsWith("https://") -> {
entries += entryFor(line, pendingName ?: defaultName(line), sourceName)
pendingName = null
}
else -> {
// relative entry - try to resolve against the playlist URL
val resolved = try {
java.net.URI(sourceUrl).resolve(line).toString()
} catch (_: Exception) {
line
}
if (resolved.startsWith("http")) {
entries += entryFor(resolved, pendingName ?: defaultName(resolved), sourceName)
}
pendingName = null
}
}
}
// PLS format: [internet radio stream] + NumberofStreams + FileN + TitleN
if (entries.isEmpty() && Regex("(?im)^File\\d+\\s*=").containsMatchIn(body)) {
val fileRe = Regex("(?im)^File(\\d+)\\s*=\\s*(.+)$")
val titleRe = Regex("(?im)^Title(\\d+)\\s*=\\s*(.+)$")
val titles = titleRe.findAll(body).associate { m ->
m.groupValues[1].trim() to m.groupValues[2].trim()
}
fileRe.findAll(body).forEach { m ->
val n = m.groupValues[1]
val url = m.groupValues[2].trim()
if (url.startsWith("http")) {
entries += entryFor(url, titles[n] ?: defaultName(url), sourceName)
}
}
}
return entries
}
private fun entryFor(url: String, name: String, source: String): StreamEntry =
StreamEntry(
id = url,
name = name,
url = url,
source = source,
group = "From $source"
)
private fun defaultName(url: String): String {
return try {
// host[:port] is the most recognizable label for a bare stream URL
val host = url.substringAfter("://").substringBefore("/")
URLDecoder.decode(host, "UTF-8").ifEmpty { url }
} catch (_: Exception) {
url
}
}
private fun directEntry(url: String): StreamEntry {
val host = url.substringAfter("://").substringBefore("/")
return StreamEntry(
id = url,
name = host,
url = url,
source = "Direct",
group = "Direct URLs"
)
}
}

View file

@ -0,0 +1,64 @@
package com.rada.data.repository
import android.content.Context
import com.rada.domain.model.StreamEntry
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StreamRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs by lazy {
context.getSharedPreferences("rada_streams", Context.MODE_PRIVATE)
}
private val key = "stream_entries"
private val json = Json { ignoreUnknownKeys = true }
private val streamEntriesSerializer = ListSerializer(StreamEntry.serializer())
private var cache: List<StreamEntry>? = null
fun getAll(): List<StreamEntry> {
cache?.let { return it }
val raw = prefs.getString(key, "[]") ?: "[]"
val list = try {
json.decodeFromString(streamEntriesSerializer, raw)
} catch (_: Exception) {
emptyList()
}
cache = list
return list
}
fun addAll(entries: List<StreamEntry>) {
val existing = getAll().associateBy { it.url }.toMutableMap()
for (e in entries) {
existing.putIfAbsent(e.url, e)
}
save(existing.values.toList())
}
fun remove(id: String) {
save(getAll().filterNot { it.id == id })
}
fun rename(id: String, newName: String) {
val trimmed = newName.trim()
if (trimmed.isEmpty()) return
save(getAll().map { entry ->
if (entry.id == id) entry.copy(name = trimmed) else entry
})
}
fun clear() {
save(emptyList())
}
private fun save(list: List<StreamEntry>) {
cache = list
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
}
}

View file

@ -0,0 +1,64 @@
package com.rada.di
import android.content.Context
import com.rada.data.local.RadAmpDatabase
import com.rada.data.local.dao.PlaylistDao
import com.rada.data.local.dao.SavedSongDao
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.StationRepository
import com.rada.player.PlaybackManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): RadAmpDatabase = RadAmpDatabase.create(context)
@Provides
@Singleton
fun provideSavedSongDao(
database: RadAmpDatabase,
): SavedSongDao = database.savedSongDao()
@Provides
@Singleton
fun providePlaylistDao(
database: RadAmpDatabase,
): PlaylistDao = database.playlistDao()
@Provides
@Singleton
fun provideStationRepository(
@ApplicationContext context: Context,
): StationRepository = StationRepository(context)
@Provides
@Singleton
fun provideLibraryRepository(
savedSongDao: SavedSongDao,
): LibraryRepository = LibraryRepository(savedSongDao)
@Provides
@Singleton
fun providePlaylistRepository(
playlistDao: PlaylistDao,
): PlaylistRepository = PlaylistRepository(playlistDao)
@Provides
@Singleton
fun providePlaybackManager(
@ApplicationContext context: Context,
libraryRepository: LibraryRepository,
): PlaybackManager = PlaybackManager(context, libraryRepository)
}

View file

@ -0,0 +1,22 @@
package com.rada.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Station(
val id: String,
val name: String,
val url: String,
val genre: String = "",
val description: String = "",
val logoUrl: String = "",
val country: String = "",
)
data class NowPlaying(
val artist: String = "",
val title: String = "",
val stationName: String = "",
val isPlaying: Boolean = false,
val station: Station? = null,
)

View file

@ -0,0 +1,17 @@
package com.rada.domain.model
import kotlinx.serialization.Serializable
/**
* A single playable network stream. Created either by adding a direct
* stream URL, or by expanding a playlist (m3u / m3u8 / pls) into its entries.
*/
@Serializable
data class StreamEntry(
val id: String,
val name: String,
val url: String,
val source: String,
val group: String,
val addedAt: Long = System.currentTimeMillis()
)

View file

@ -0,0 +1,377 @@
package com.rada.player
import android.content.ComponentName
import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.rada.data.repository.LibraryRepository
import com.rada.recording.IcyStreamReader
import com.rada.recording.MetadataTimeline
import com.rada.recording.RollingAudioBuffer
import com.rada.recording.SaveSongUseCase
import com.rada.recording.StreamMetadata
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Singleton
@Singleton
class PlaybackManager(
@ApplicationContext private val context: Context,
private val libraryRepository: LibraryRepository,
) {
private var controllerFuture: ListenableFuture<MediaController>? = null
private var controller: MediaController? = null
private var icyReader: IcyStreamReader? = null
val metadataTimeline = MetadataTimeline()
var audioBuffer: RollingAudioBuffer? = null
private set
private lateinit var saveSongUseCase: SaveSongUseCase
private var scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var isPlaying: Boolean = false
private set
var currentStationUrl: String? = null
private set
var currentStationName: String = ""
private set
var bufferState: BufferState = BufferState.IDLE
private set
var saveState: SaveState = SaveState.IDLE
private set
var lastSaveResult: SaveSongUseCase.SaveResult? = null
private set
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var readyAtMs = 0L
var listener: PlaybackListener? = null
private val _currentStationNameFlow = MutableStateFlow("")
val currentStationNameFlow: StateFlow<String> = _currentStationNameFlow.asStateFlow()
private val _currentStationUrlFlow = MutableStateFlow<String?>(null)
val currentStationUrlFlow: StateFlow<String?> = _currentStationUrlFlow.asStateFlow()
private val _isPlayingFlow = MutableStateFlow(false)
val isPlayingFlow: StateFlow<Boolean> = _isPlayingFlow.asStateFlow()
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
enum class BufferState {
IDLE, INITIALIZING, BUFFERING, READY, ERROR,
}
enum class SaveState {
IDLE, SAVING, SUCCESS, ERROR,
}
interface PlaybackListener {
fun onPlaybackStateChanged(isPlaying: Boolean)
fun onMetadataUpdate(artist: String, title: String)
fun onError(message: String)
fun onIcyMetadata(metadata: StreamMetadata)
fun onBufferStateChanged(state: BufferState)
fun onBufferBytesWritten(totalBytes: Long)
fun onSaveStateChanged(state: SaveState, result: SaveSongUseCase.SaveResult?)
fun onStationChanged(name: String, url: String)
}
fun connect() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
val sessionToken = SessionToken(
context,
ComponentName(context, PlaybackService::class.java)
)
controllerFuture = MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture?.addListener({
controller = controllerFuture?.get()
controller?.addListener(playerListener)
}, MoreExecutors.directExecutor())
}
fun disconnect() {
stopAll()
scope.cancel()
controller?.removeListener(playerListener)
controller?.release()
controller = null
controllerFuture?.cancel(true)
}
fun playStation(url: String, name: String) {
currentStationUrl = url
currentStationName = name
_currentStationUrlFlow.value = url
_currentStationNameFlow.value = name
val ctrl = controller ?: return
stopAll()
initBuffer()
listener?.onStationChanged(name, url)
val mediaItem = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(name)
.setTitle("")
.build()
)
.build()
ctrl.setMediaItem(mediaItem)
ctrl.prepare()
ctrl.play()
startIcyReader(url)
}
fun playLocalFile(filePath: String, title: String, artist: String) {
currentStationUrl = null
currentStationName = ""
val ctrl = controller ?: return
stopAll()
val mediaItem = MediaItem.Builder()
.setUri(filePath)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(artist)
.setTitle(title)
.build()
)
.build()
ctrl.setMediaItem(mediaItem)
ctrl.prepare()
ctrl.play()
}
fun togglePlayPause() {
val ctrl = controller ?: return
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
}
fun stop() {
stopAll()
controller?.stop()
isPlaying = false
listener?.onPlaybackStateChanged(false)
}
fun saveCurrentSong() {
val buffer = audioBuffer
if (buffer == null || bufferState != BufferState.READY) {
listener?.onSaveStateChanged(SaveState.ERROR,
SaveSongUseCase.SaveResult(false, error = "Buffer not ready"))
return
}
if (saveState == SaveState.SAVING) return
updateSaveState(SaveState.SAVING, null)
scope.launch {
val result = saveSongUseCase.save(
buffer = buffer,
metadataTimeline = metadataTimeline,
currentMetadata = metadataTimeline.currentMetadata,
stationName = currentStationName,
)
withContext(Dispatchers.Main) {
lastSaveResult = result
if (result.success) {
updateSaveState(SaveState.SUCCESS, result)
} else {
updateSaveState(SaveState.ERROR, result)
}
delay(3000)
if (saveState == SaveState.SUCCESS || saveState == SaveState.ERROR) {
updateSaveState(SaveState.IDLE, null)
}
}
}
}
private fun stopAll() {
reconnectJob?.cancel()
reconnectJob = null
reconnectAttempt = 0
readyAtMs = 0L
stopIcyReader()
releaseBuffer()
metadataTimeline.clear()
updateSaveState(SaveState.IDLE, null)
}
private fun initBuffer() {
updateBufferState(BufferState.INITIALIZING)
val buffer = RollingAudioBuffer(context)
buffer.initialize()
audioBuffer = buffer
updateBufferState(BufferState.BUFFERING)
}
private fun releaseBuffer() {
audioBuffer?.release()
audioBuffer = null
updateBufferState(BufferState.IDLE)
}
private fun startIcyReader(url: String) {
val reader = IcyStreamReader()
icyReader = reader
reader.listener = object : IcyStreamReader.Listener {
override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) {
Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata")
// Record at the reader-reported ABSOLUTE audio position (the same
// quantity as audioBuffer.totalBytes). The previous code read
// audioBuffer.currentWritePosition here, which is (a) a racy
// cross-thread read and (b) a WRAPPED file position (0..max-1),
// not an absolute one — that mismatch is what corrupted
// boundaries after the buffer wrapped.
metadataTimeline.record(metadata, audioBytesAtMetadata)
listener?.onIcyMetadata(metadata)
listener?.onMetadataUpdate(metadata.artist, metadata.title)
}
override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) {
audioBuffer?.write(data, offset, length.toInt())
listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0)
if (bufferState == BufferState.BUFFERING || bufferState == BufferState.INITIALIZING) {
updateBufferState(BufferState.READY)
}
}
override fun onError(error: String) {
Log.w(TAG, "ICY reader error: $error")
}
override fun onConnected(metaInterval: Int) {
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
}
}
reader.start(url)
}
private fun stopIcyReader() {
icyReader?.stop()
icyReader = null
}
private fun updateBufferState(state: BufferState) {
bufferState = state
_bufferStateFlow.value = state
listener?.onBufferStateChanged(state)
}
private fun updateSaveState(state: SaveState, result: SaveSongUseCase.SaveResult?) {
saveState = state
listener?.onSaveStateChanged(state, result)
}
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
_isPlayingFlow.value = playing
listener?.onPlaybackStateChanged(playing)
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
readyAtMs = SystemClock.elapsedRealtime()
listener?.onPlaybackStateChanged(true)
}
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false)
}
}
override fun onPlayerError(error: PlaybackException) {
val description = describeError(error)
updateBufferState(BufferState.ERROR)
stopIcyReader()
val reconnectDelayMs = scheduleReconnect()
val message = if (reconnectDelayMs != null) {
"$description — reconnecting in ${reconnectDelayMs / 1000}s"
} else {
description
}
listener?.onError(message)
}
}
private fun describeError(error: PlaybackException): String {
val chain = generateSequence<Throwable>(error) { it.cause }.take(10).toList()
Log.e(
TAG,
"Playback error (code=${error.errorCode}): " +
chain.joinToString(" <- ") { "${it.javaClass.simpleName}: ${it.message}" }
)
val root = chain.last()
val message = root.message?.trim()
return if (message.isNullOrEmpty()) root.javaClass.simpleName
else "${root.javaClass.simpleName}: $message"
}
private fun scheduleReconnect(): Long? {
val url = currentStationUrl ?: return null
if (reconnectJob?.isActive == true) return null
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
reconnectAttempt = 0
}
val delayMs = (RECONNECT_BASE_DELAY_MS shl reconnectAttempt.coerceAtMost(5))
.coerceAtMost(RECONNECT_MAX_DELAY_MS)
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
Log.d(TAG, "Auto-reconnect #$reconnectAttempt to $url in ${delayMs / 1000}s")
reconnectJob = scope.launch {
delay(delayMs)
if (currentStationUrl == url) {
reconnectJob = null
playStation(url, currentStationName)
}
}
return delayMs
}
companion object {
private const val TAG = "PlaybackManager"
private const val RECONNECT_BASE_DELAY_MS = 1000L
private const val RECONNECT_MAX_DELAY_MS = 30_000L
private const val RECONNECT_STABLE_MS = 10_000L
}
}

View file

@ -0,0 +1,56 @@
package com.rada.player
import android.app.PendingIntent
import android.content.Intent
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.rada.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
val player = ExoPlayer.Builder(this).build()
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
mediaSession = MediaSession.Builder(this, player)
.setSessionActivity(pendingIntent)
.build()
}
@OptIn(UnstableApi::class)
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession = null
super.onDestroy()
}
}

View file

@ -0,0 +1,144 @@
package com.rada.recording
import android.content.Context
import android.util.Log
import java.io.File
class AudioExtractor(private val context: Context) {
data class ExtractResult(
val file: File,
val bytesExtracted: Long,
val isPartial: Boolean,
)
fun extract(
buffer: RollingAudioBuffer,
boundaries: SongBoundaries,
): ExtractResult? {
// Bug #1: `boundaries.startByte` / `endByte` are ABSOLUTE audio byte
// positions (the canonical coordinate). All file-offset conversion is
// handled inside RollingAudioBuffer.readSegment — nothing is wrapped
// here.
val segmentBytes = boundaries.endByte - boundaries.startByte
if (segmentBytes <= 0) {
Log.w(TAG, "Invalid segment: start=${boundaries.startByte}, end=${boundaries.endByte}")
return null
}
// Read segment from buffer (absolute positions in; offsets out).
val audioData = buffer.readSegment(boundaries.startByte, boundaries.endByte)
if (audioData == null) {
Log.e(TAG, "Failed to read segment from buffer")
return null
}
// Find the actual start of audio (skip any ID3 tag / pre-roll at the
// beginning). Bug #7: this now validates the full MPEG frame header,
// not just the sync word.
val audioStart = findAudioStart(audioData)
val trimmedAudio = if (audioStart > 0) {
audioData.copyOfRange(audioStart, audioData.size)
} else {
audioData
}
// Write to temp file
val tempFile = File(context.cacheDir, "rada_save_${System.currentTimeMillis()}.mp3")
try {
tempFile.writeBytes(trimmedAudio)
Log.d(TAG, "Extracted ${trimmedAudio.size} bytes to ${tempFile.absolutePath}")
return ExtractResult(
file = tempFile,
bytesExtracted = trimmedAudio.size.toLong(),
isPartial = boundaries.isPartial,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to write extracted audio", e)
tempFile.delete()
return null
}
}
/**
* Finds the byte offset of the first valid MPEG audio frame in [data].
*
* Bug #7: the previous version returned the first offset whose two leading
* bytes looked like a sync word (`0xFF` + `111xxxxx`). A radio stream is a
* continuous byte feed, so any run of bytes containing `0xFF` followed by
* a byte with the top three bits set would pass — producing frames that
* start mid-sample and decode as noise. We now require the full 4-byte
* MPEG frame header to be structurally valid (version, layer, bitrate and
* sampling-rate indexes all in-range) before accepting a frame start.
*/
private fun findAudioStart(data: ByteArray): Int {
var offset = 0
// Skip a leading ID3v2 tag if present.
if (data.size >= 10 &&
data[0] == 'I'.code.toByte() &&
data[1] == 'D'.code.toByte() &&
data[2] == '3'.code.toByte()
) {
val tagSize = ((data[6].toInt() and 0x7F) shl 21) or
((data[7].toInt() and 0x7F) shl 14) or
((data[8].toInt() and 0x7F) shl 7) or
(data[9].toInt() and 0x7F)
offset = tagSize + 10
if (offset > data.size) offset = 0
}
// Scan for a sync word whose full MPEG header validates.
while (offset + 3 < data.size) {
if (data[offset].toInt() and 0xFF == 0xFF &&
data[offset + 1].toInt() and 0xE0 == 0xE0 &&
isValidMpegFrameHeader(data, offset)
) {
return offset
}
offset++
}
return 0
}
/**
* Validates the four-byte MPEG frame header at [offset].
*
* Layout (bits are 1-indexed from the most-significant bit of each byte):
* - byte 0: 0xFF (sync)
* - byte 1: [7-5] sync (111) | [4-3] version | [2-1] layer | [0] protection
* - byte 2: [7-4] bitrate index | [3-2] sampling-rate index | [1] padding | [0] private
* - byte 3: (channel mode, emphasis, ...) — not validated here
*
* Rejection rules:
* - version 00b is reserved (reject); 01 (MPEG-2), 10 (MPEG-2.5), 11 (MPEG-1) are valid.
* - layer 11b is reserved (reject); 00 (III), 01 (II), 10 (I) are valid.
* - bitrate index 0000b (free) and 1111b (bad) are invalid for fixed streams.
* - sampling-rate index 11b is reserved (reject).
*/
private fun isValidMpegFrameHeader(data: ByteArray, offset: Int): Boolean {
if (offset + 3 >= data.size) return false
val b1 = data[offset + 1].toInt() and 0xFF
val b2 = data[offset + 2].toInt() and 0xFF
val version = (b1 ushr 3) and 0b11
if (version == 0b00) return false // reserved
val layer = (b1 ushr 1) and 0b11
if (layer == 0b11) return false // reserved
val bitrateIndex = (b2 ushr 4) and 0b1111
if (bitrateIndex == 0b0000 || bitrateIndex == 0b1111) return false // free / bad
val sampleRateIndex = (b2 ushr 2) and 0b11
if (sampleRateIndex == 0b11) return false // reserved
return true
}
companion object {
private const val TAG = "AudioExtractor"
}
}

View file

@ -0,0 +1,216 @@
package com.rada.recording
import android.util.Log
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
import java.util.concurrent.TimeUnit
class IcyStreamReader(
private val okHttpClient: OkHttpClient = defaultClient(),
) {
private var scope: CoroutineScope? = null
private var inputStream: InputStream? = null
private var connection: okhttp3.Response? = null
var listener: Listener? = null
var isRunning: Boolean = false
private set
private var metaInterval: Int = 0
private var bytesInInterval: Int = 0
private var pendingMetaBytes: Int = 0
private var waitingForMetaLength: Boolean = false
private var metadataBuffer = StringBuilder()
private var totalBytesRead: Long = 0
/**
* Absolute audio byte position (audio bytes consumed since the connection
* started, ICY metadata bytes excluded) at which the most recent metadata
* was received.
*
* This is the canonical coordinate for song boundaries: it is the same
* quantity as [RollingAudioBuffer.totalBytes]. File offsets are derived
* from it ONLY inside [RollingAudioBuffer.readSegment].
*/
private var audioBytesAtMetadata: Long = 0
interface Listener {
/**
* @param metadata Parsed stream title.
* @param audioBytesAtMetadata Absolute audio byte position (audio-only
* bytes consumed since the connection started, ICY metadata excluded)
* at which [metadata] was received. Matches
* [RollingAudioBuffer.totalBytes].
*/
fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long)
fun onAudioBytes(data: ByteArray, offset: Int, length: Long)
fun onError(error: String)
fun onConnected(metaInterval: Int)
}
fun start(streamUrl: String) {
if (isRunning) stop()
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope?.launch {
try {
connectAndRead(streamUrl)
} catch (e: CancellationException) {
// Normal stop
} catch (e: Exception) {
Log.e(TAG, "Error reading stream", e)
listener?.onError(e.message ?: "Connection error")
} finally {
isRunning = false
}
}
}
fun stop() {
scope?.cancel()
scope = null
closeConnection()
isRunning = false
resetState()
}
private fun resetState() {
metaInterval = 0
bytesInInterval = 0
pendingMetaBytes = 0
waitingForMetaLength = false
metadataBuffer.clear()
totalBytesRead = 0
audioBytesAtMetadata = 0
}
private fun connectAndRead(url: String) {
val request = Request.Builder()
.url(url)
.header("Icy-MetaData", "1")
.header("User-Agent", "RadAmp/0.1")
.build()
val response = okHttpClient.newCall(request).execute()
connection = response
if (!response.isSuccessful) {
listener?.onError("HTTP ${response.code}")
return
}
val icyMetaInt = response.header("icy-metaint")
metaInterval = icyMetaInt?.toIntOrNull() ?: 0
if (metaInterval > 0) {
listener?.onConnected(metaInterval)
}
isRunning = true
inputStream = response.body?.byteStream() ?: return
readStream()
}
private fun readStream() {
val stream = inputStream ?: return
val buffer = ByteArray(8192)
while (isRunning) {
val bytesRead = stream.read(buffer)
if (bytesRead == -1) break
if (metaInterval > 0) {
processBytesWithMetadata(buffer, bytesRead)
} else {
// No ICY metadata - all bytes are audio
totalBytesRead += bytesRead
listener?.onAudioBytes(buffer.copyOf(bytesRead), 0, bytesRead.toLong())
}
}
if (isRunning) {
listener?.onError("Stream ended")
}
}
private fun processBytesWithMetadata(buffer: ByteArray, length: Int) {
var offset = 0
while (offset < length && isRunning) {
when {
waitingForMetaLength -> {
val metaLength = (buffer[offset].toInt() and 0xFF) * 16
offset++
waitingForMetaLength = false
if (metaLength > 0) {
pendingMetaBytes = metaLength
metadataBuffer.clear()
}
}
pendingMetaBytes > 0 -> {
val toRead = minOf(pendingMetaBytes, length - offset)
val chunk = String(buffer, offset, toRead, Charsets.US_ASCII)
metadataBuffer.append(chunk)
offset += toRead
pendingMetaBytes -= toRead
if (pendingMetaBytes <= 0) {
val rawMeta = metadataBuffer.toString()
.trim('\u0000')
.trim()
if (rawMeta.isNotEmpty()) {
val metadata = StreamMetadata.parseStreamTitle(rawMeta)
// Absolute audio position at which this metadata was
// received: audio bytes consumed so far (metadata
// bytes are never counted in totalBytesRead).
audioBytesAtMetadata = totalBytesRead
listener?.onMetadata(metadata, audioBytesAtMetadata)
}
metadataBuffer.clear()
}
}
else -> {
// Reading audio data
val bytesUntilMeta = metaInterval - bytesInInterval
val available = length - offset
val toProcess = minOf(bytesUntilMeta, available)
if (toProcess > 0) {
// Emit audio bytes
listener?.onAudioBytes(buffer, offset, toProcess.toLong())
offset += toProcess
bytesInInterval += toProcess
totalBytesRead += toProcess
}
if (bytesInInterval >= metaInterval) {
bytesInInterval = 0
waitingForMetaLength = true
}
}
}
}
}
private fun closeConnection() {
try { inputStream?.close() } catch (_: Exception) {}
try { connection?.close() } catch (_: Exception) {}
inputStream = null
connection = null
}
companion object {
private const val TAG = "IcyStreamReader"
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.build()
}
}

View file

@ -0,0 +1,85 @@
package com.rada.recording
import android.content.Context
import android.util.Log
import org.jaudiotagger.audio.AudioFile
import org.jaudiotagger.audio.AudioFileIO
import org.jaudiotagger.tag.FieldKey
import java.io.File
class MetadataEmbedder(private val context: Context) {
data class EmbedResult(
val file: File,
val artist: String,
val title: String,
val album: String,
)
/**
* Bug #6: the jaudiotagger [AudioFile] reference is now stored in a local
* and written back through that SAME reference (the previous code took
* `.tag` off an orphaned [AudioFile] and then called
* `AudioFileIO.write(audioFile)` passing the raw `java.io.File`, which
* neither applies the tag mutations nor type-checks against
* `AudioFileIO.write(AudioFile)`). The read/write now happen inside a
* try/finally so the reference is released deterministically on every
* path, success or failure.
*/
fun embed(
audioFile: File,
artist: String,
title: String,
stationName: String = "",
outputFormat: String = "mp3",
): EmbedResult? {
var audio: AudioFile? = null
return try {
// Store the jaudiotagger AudioFile reference (Bug #6).
audio = AudioFileIO.read(audioFile)
// Set metadata fields on the stored reference's tag.
val tag = audio.tag
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "RadAmp" })
tag.setField(FieldKey.GENRE, "Radio")
tag.setField(FieldKey.COMMENT, "Recorded from $stationName")
// Write back the SAME AudioFile reference holding the changes.
AudioFileIO.write(audio)
Log.d(TAG, "Embedded metadata: $artist - $title")
EmbedResult(
file = audioFile,
artist = artist,
title = title,
album = stationName,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to embed metadata", e)
// Return without metadata embedding - file still exists
EmbedResult(
file = audioFile,
artist = artist,
title = title,
album = stationName,
)
} finally {
// Bug #6: release the jaudiotagger AudioFile reference on every
// path. NOTE: jaudiotagger 2.2.5's AudioFile is NOT
// java.io.Closeable — it exposes no close() (verified against the
// published 2.2.5/3.0.1 jars), and AudioFileIO.read()/write()
// open and close their own RandomAccessFile per call. There is
// therefore nothing to close(); dropping the reference here is
// the deterministic release. If a Closeable jaudiotagger release
// is ever adopted, call audio.close() inside this block.
audio = null
}
}
companion object {
private const val TAG = "MetadataEmbedder"
}
}

View file

@ -0,0 +1,84 @@
package com.rada.recording
import java.util.concurrent.CopyOnWriteArrayList
class MetadataTimeline {
data class Entry(
val timestamp: Long,
val byteOffset: Long,
val metadata: StreamMetadata,
)
/**
* [CopyOnWriteArrayList] rather than a synchronized block because entries
* are written once per song (low frequency) and read on every save and by
* the detector — copy-on-write keeps reads lock-free and allocation-cheap
* at this write rate.
*/
private val entries = CopyOnWriteArrayList<Entry>()
@Volatile
private var lastMetadata: StreamMetadata? = null
@Volatile
private var lastByteOffset: Long = 0
val currentMetadata: StreamMetadata?
get() = lastMetadata
val allEntries: List<Entry>
get() = entries.toList()
/**
* @param metadata Parsed stream title.
* @param byteOffset ABSOLUTE audio byte position (audio bytes consumed
* since the stream connection started — matches
* [RollingAudioBuffer.totalBytes]). Never a wrapped file offset.
*/
fun record(metadata: StreamMetadata, byteOffset: Long) {
// Only record if the artist/title actually changed. (The previous
// condition also demanded BOTH fields be empty, so identical
// re-broadcasts of a non-empty title were recorded as new entries,
// and a genuine title change was only filtered when the new title
// happened to be empty.)
val prev = lastMetadata
if (prev != null &&
prev.artist == metadata.artist &&
prev.title == metadata.title
) {
return
}
val entry = Entry(
timestamp = System.currentTimeMillis(),
byteOffset = byteOffset,
metadata = metadata.copy(byteOffset = byteOffset),
)
entries.add(entry)
lastMetadata = metadata
lastByteOffset = byteOffset
}
/**
* The second-to-last entry marks the start of the current song; the last
* entry is the metadata change that produced the song currently playing.
* With fewer than two entries the only entry is the best we have.
*/
fun findSongStart(): Entry? {
if (entries.size < 2) return entries.lastOrNull()
// The second-to-last entry marks the start of the current song
return entries.getOrNull(entries.size - 2)
}
fun findCurrentSongEntries(): List<Entry> {
if (entries.isEmpty()) return emptyList()
// All entries from the second-to-last (song start) to the end
val startIdx = maxOf(0, entries.size - 2)
return entries.subList(startIdx, entries.size)
}
fun clear() {
entries.clear()
lastMetadata = null
lastByteOffset = 0
}
}

View file

@ -0,0 +1,183 @@
package com.rada.recording
import android.content.Context
import android.util.Log
import java.io.File
import java.io.RandomAccessFile
class RollingAudioBuffer(
context: Context,
private val maxBytes: Long = DEFAULT_MAX_BYTES,
) {
private val bufferFile: File = File(context.cacheDir, "rada_buffer_${System.currentTimeMillis()}.tmp")
private var raf: RandomAccessFile? = null
@Volatile
private var writePos: Long = 0
@Volatile
private var totalBytesWritten: Long = 0
@Volatile
private var hasWrapped: Boolean = false
private var isInitialized = false
/**
* ABSOLUTE audio byte position of the next byte to be written: total audio
* bytes consumed since the buffer was created (never wraps, keeps growing).
* This is the canonical coordinate used by [IcyStreamReader],
* [MetadataTimeline] and [SongBoundaryDetector].
*/
val currentWritePosition: Long get() = totalBytesWritten
/** Total audio bytes written so far (same coordinate as [currentWritePosition]). */
val totalBytes: Long get() = totalBytesWritten
val isWrapped: Boolean get() = hasWrapped
val bytesAvailable: Long
get() = if (hasWrapped) maxBytes else totalBytesWritten
/** Fixed capacity of the underlying file in bytes (never grows). */
val maxCapacity: Long get() = maxBytes
val bufferFileAbsolutePath: String get() = bufferFile.absolutePath
fun initialize() {
if (isInitialized) return
try {
raf = RandomAccessFile(bufferFile, "rw")
raf?.setLength(maxBytes)
isInitialized = true
Log.d(TAG, "Buffer initialized: ${bufferFile.absolutePath} ($maxBytes bytes)")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize buffer", e)
}
}
fun write(data: ByteArray, offset: Int, length: Int) {
val file = raf ?: return
if (!isInitialized) return
try {
var remaining = length
var srcOffset = offset
while (remaining > 0) {
val writeChunk = minOf(remaining.toLong(), maxBytes - writePos).toInt()
file.seek(writePos)
file.write(data, srcOffset, writeChunk)
writePos += writeChunk
totalBytesWritten += writeChunk
srcOffset += writeChunk
remaining -= writeChunk
// Wrap around
if (writePos >= maxBytes) {
writePos = 0
hasWrapped = true
}
}
} catch (e: Exception) {
Log.e(TAG, "Write error", e)
}
}
/**
* Reads a segment of buffered audio.
*
* [startByte] and [endByte] are ABSOLUTE audio byte positions in the
* canonical coordinate (total audio bytes since the buffer was created —
* the same space as [currentWritePosition]/[totalBytes]). The conversion
* to file offsets happens ONLY here, inside this method: every caller
* passes absolute positions and never touches file offsets.
*
* Valid range: [max(0, totalBytesWritten - maxBytes), totalBytesWritten].
* Returns null if the range is invalid or (before the first wrap) extends
* beyond the data written so far.
*/
fun readSegment(startByte: Long, endByte: Long): ByteArray? {
val file = raf ?: return null
if (!isInitialized) return null
if (startByte < 0 || endByte <= startByte) return null
// Before the first wrap, data only exists in [0, writePos); reject
// reads that reach into not-yet-written file space (previously this
// check was missing in the wrapped path and broken in the plain path).
if (!hasWrapped && (startByte >= totalBytesWritten || endByte > totalBytesWritten)) {
return null
}
val length = (endByte - startByte).toInt()
if (length <= 0 || length > maxBytes) return null
// Absolute position -> file offset. This is the only place in the app
// where file offsets are computed.
val startOffset = startByte % maxBytes
val endOffset = endByte % maxBytes
return try {
val result = ByteArray(length)
if (endOffset > startOffset) {
// Segment does not cross the end of the file
file.seek(startOffset)
file.readFully(result)
} else {
// Segment crosses the wrap boundary: read to end of file,
// then continue from the beginning.
val firstPart = (maxBytes - startOffset).toInt()
file.seek(startOffset)
file.readFully(result, 0, firstPart)
file.seek(0)
file.readFully(result, firstPart, length - firstPart)
}
result
} catch (e: Exception) {
Log.e(TAG, "Read error", e)
null
}
}
fun getWritePositionForByte(targetByte: Long): Long {
return if (hasWrapped) {
targetByte % maxBytes
} else {
targetByte
}
}
fun clear() {
writePos = 0
totalBytesWritten = 0
hasWrapped = false
}
fun release() {
try {
raf?.close()
} catch (_: Exception) {}
raf = null
isInitialized = false
try {
if (bufferFile.exists()) {
bufferFile.delete()
}
} catch (_: Exception) {}
}
companion object {
private const val TAG = "RollingAudioBuffer"
// Default: 5 minutes at 128kbps = 4.8 MB
// 128 kbps = 16,000 bytes/sec × 300 sec = 4,800,000 bytes
const val DEFAULT_MAX_BYTES = 5_000_000L
fun createForDuration(context: Context, durationSeconds: Int, bitrateBytesPerSec: Int = 16000): RollingAudioBuffer {
val maxBytes = durationSeconds.toLong() * bitrateBytesPerSec
return RollingAudioBuffer(context, maxBytes)
}
}
}

View file

@ -0,0 +1,150 @@
package com.rada.recording
import android.content.Context
import android.os.Environment
import android.util.Log
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.LibraryRepository
import java.io.File
class SaveSongUseCase(
private val context: Context,
private val libraryRepository: LibraryRepository,
) {
private val boundaryDetector = SongBoundaryDetector()
private val audioExtractor = AudioExtractor(context)
private val metadataEmbedder = MetadataEmbedder(context)
data class SaveResult(
val success: Boolean,
val filePath: String? = null,
val fileName: String? = null,
val songId: Long? = null,
val isPartial: Boolean = false,
val error: String? = null,
)
suspend fun save(
buffer: RollingAudioBuffer,
metadataTimeline: MetadataTimeline,
currentMetadata: StreamMetadata?,
stationName: String,
): SaveResult {
// 1. Detect song boundaries
val boundaries = boundaryDetector.detectBoundaries(
timeline = metadataTimeline,
currentPositionBytes = buffer.currentWritePosition,
currentMetadata = currentMetadata,
bufferTotalBytes = buffer.totalBytes,
bufferMaxBytes = buffer.maxCapacity,
bufferHasWrapped = buffer.isWrapped,
)
if (boundaries == null) {
return SaveResult(
success = false,
error = "No metadata available. Cannot determine song boundaries.",
)
}
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
val minBytes = 160_000L
if (boundaries.durationBytes < minBytes) {
return SaveResult(
success = false,
error = "Not enough audio buffered. Wait for the song to play longer.",
)
}
// 2. Extract audio segment (returns null on failure)
val extractResult = audioExtractor.extract(buffer, boundaries)
if (extractResult == null) {
return SaveResult(
success = false,
error = "Failed to extract audio from buffer.",
)
}
// Keep the extracted file alive until the end of the save; it is
// deleted in the finally block regardless of success/failure so a
// thrown exception can never leak a temp file.
val extractedFile = extractResult.file
return try {
// 3. Embed metadata (never fails hard — returns the file as-is)
metadataEmbedder.embed(
audioFile = extractedFile,
artist = boundaries.artist,
title = boundaries.title,
stationName = stationName,
)
// 4. Move to permanent storage
val outputDir = getSaveDirectory()
if (!outputDir.exists()) {
outputDir.mkdirs()
}
val outputFile = File(outputDir, "${boundaries.fileName}.mp3")
// Handle duplicate names
val finalFile = if (outputFile.exists()) {
var counter = 1
var candidate = File(outputDir, "${boundaries.fileName} ($counter).mp3")
while (candidate.exists()) {
counter++
candidate = File(outputDir, "${boundaries.fileName} ($counter).mp3")
}
candidate
} else {
outputFile
}
extractedFile.copyTo(finalFile, overwrite = true)
extractedFile.delete()
Log.d(TAG, "Saved song: ${finalFile.absolutePath}")
// 5. Insert into library database
val entity = SavedSongEntity(
fileName = finalFile.name,
filePath = finalFile.absolutePath,
artist = boundaries.artist.ifEmpty { stationName },
title = boundaries.title.ifEmpty { "Unknown Title" },
album = stationName,
stationName = stationName,
fileSize = finalFile.length(),
format = "mp3",
)
val songId = libraryRepository.insert(entity)
SaveResult(
success = true,
filePath = finalFile.absolutePath,
fileName = finalFile.name,
songId = songId,
isPartial = boundaries.isPartial,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to save file", e)
SaveResult(
success = false,
error = "Failed to save file: ${e.message}",
)
} finally {
// Guarantee: the temp file is always removed, even if embedding,
// the copy, or the DB insert threw.
if (extractedFile.exists()) {
extractedFile.delete()
}
}
}
private fun getSaveDirectory(): File {
val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)
return File(musicDir, "RadAmp")
}
companion object {
private const val TAG = "SaveSongUseCase"
}
}

View file

@ -0,0 +1,140 @@
package com.rada.recording
data class SongBoundaries(
val startByte: Long,
val endByte: Long,
val artist: String,
val title: String,
val stationName: String,
val isPartial: Boolean = false,
) {
val durationBytes: Long get() = endByte - startByte
val fileName: String
get() {
val name = if (artist.isNotEmpty() && title.isNotEmpty()) {
"$artist - $title"
} else if (title.isNotEmpty()) {
title
} else if (artist.isNotEmpty()) {
artist
} else {
"Unknown"
}
return sanitizeFileName(name)
}
companion object {
private val ILLEGAL_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
fun sanitizeFileName(name: String): String {
return name.map { c ->
if (c in ILLEGAL_CHARS || c.code < 32) '_' else c
}.joinToString("")
}
}
}
class SongBoundaryDetector {
/**
* Bytes shaved off the END of a detected segment. ICY StreamTitle is
* broadcast with up to ~1 second of delay after the song actually starts,
* and the stream may contain pre-roll between songs, so a hard cut at
* the metadata byte offset can land in the tail of the PREVIOUS song.
* Trimming a small safety margin keeps the cut inside the current song.
*/
private val END_SAFETY_MARGIN_BYTES = 8_000L // ~0.5s at 128 kbps
fun detectBoundaries(
timeline: MetadataTimeline,
currentPositionBytes: Long,
currentMetadata: StreamMetadata?,
bufferTotalBytes: Long,
bufferMaxBytes: Long,
bufferHasWrapped: Boolean,
): SongBoundaries? {
val entries = timeline.allEntries
if (entries.isEmpty()) return null
// Song start: the second-to-last timeline entry marks the start of the
// CURRENT song — the last entry is the change that brought us HERE
// (i.e. the previous song's tail). Using the last entry would make the
// segment cover only the gap since the previous song's metadata, which
// is wrong whenever that gap is short.
val startEntry = timeline.findSongStart() ?: return null
val artist = currentMetadata?.artist?.ifEmpty { startEntry.metadata.artist }
?: startEntry.metadata.artist
val title = currentMetadata?.title?.ifEmpty { startEntry.metadata.title }
?: startEntry.metadata.title
val stationName = "" // Will be filled by caller
val rawStartByte = startEntry.byteOffset
val endByte = currentPositionBytes
// Safety margin: never cut closer than the margin to the write head,
// and never start before byte 0.
val startByte = rawStartByte.coerceAtLeast(0L)
val safeEndByte = (endByte - END_SAFETY_MARGIN_BYTES).coerceAtLeast(startByte)
// Check if we have enough buffer for this segment
val segmentSize = safeEndByte - startByte
val availableBytes = if (bufferHasWrapped) bufferMaxBytes else bufferTotalBytes
val isPartial = segmentSize > availableBytes
return SongBoundaries(
startByte = if (isPartial) {
// Buffer doesn't cover the full song - start from earliest available
maxOf(startByte, safeEndByte - availableBytes)
} else {
startByte
},
endByte = safeEndByte,
artist = artist,
title = title,
stationName = stationName,
isPartial = isPartial,
)
}
fun detectBoundariesFromEntries(
entries: List<MetadataTimeline.Entry>,
currentPositionBytes: Long,
currentMetadata: StreamMetadata?,
bufferTotalBytes: Long,
bufferMaxBytes: Long,
bufferHasWrapped: Boolean,
): SongBoundaries? {
if (entries.isEmpty()) return null
// Same rule as detectBoundaries: second-to-last entry = current song start.
val startEntry = entries.getOrNull((entries.size - 2).coerceAtLeast(0))
?: return null
val artist = currentMetadata?.artist?.ifEmpty { startEntry.metadata.artist }
?: startEntry.metadata.artist
val title = currentMetadata?.title?.ifEmpty { startEntry.metadata.title }
?: startEntry.metadata.title
val rawStartByte = startEntry.byteOffset
val endByte = currentPositionBytes
val startByte = rawStartByte.coerceAtLeast(0L)
val safeEndByte = (endByte - END_SAFETY_MARGIN_BYTES).coerceAtLeast(startByte)
val segmentSize = safeEndByte - startByte
val availableBytes = if (bufferHasWrapped) bufferMaxBytes else bufferTotalBytes
val isPartial = segmentSize > availableBytes
return SongBoundaries(
startByte = if (isPartial) {
maxOf(startByte, safeEndByte - availableBytes)
} else {
startByte
},
endByte = safeEndByte,
artist = artist,
title = title,
stationName = "",
isPartial = isPartial,
)
}
}

View file

@ -0,0 +1,42 @@
package com.rada.recording
data class StreamMetadata(
val artist: String = "",
val title: String = "",
val raw: String = "",
val timestamp: Long = System.currentTimeMillis(),
val byteOffset: Long = 0,
) {
val displayArtist: String
get() = artist.ifEmpty { title }
val displayTitle: String
get() = if (artist.isNotEmpty() && title.isNotEmpty()) title else ""
companion object {
fun parseStreamTitle(raw: String): StreamMetadata {
val cleaned = raw.trim().removeSuffix(";")
var artist = ""
var title = ""
// Parse StreamTitle='Artist - Title'
val titleMatch = Regex("""StreamTitle='([^']*)'""").find(cleaned)
if (titleMatch != null) {
val value = titleMatch.groupValues[1].trim()
if (value.contains(" - ")) {
val parts = value.split(" - ", limit = 2)
artist = parts[0].trim()
title = parts[1].trim()
} else {
title = value
}
}
return StreamMetadata(
artist = artist,
title = title,
raw = raw,
)
}
}
}

View file

@ -0,0 +1,44 @@
package com.rada.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import com.rada.player.PlaybackManager
import com.rada.ui.navigation.NavGraph
import com.rada.ui.theme.RadAmpTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var playbackManager: PlaybackManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
playbackManager.connect()
setContent {
RadAmpTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
NavGraph(navController = rememberNavController())
}
}
}
}
override fun onDestroy() {
playbackManager.disconnect()
super.onDestroy()
}
}

View file

@ -0,0 +1,343 @@
package com.rada.ui.library
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
onBack: () -> Unit,
initialPlaylistId: Long? = null,
viewModel: LibraryViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val filteredPlaylists by viewModel.filteredPlaylists.collectAsState()
val allPlaylists by viewModel.allPlaylists.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showSortMenu by remember { mutableStateOf(false) }
LaunchedEffect(initialPlaylistId) {
if (initialPlaylistId != null) {
viewModel.selectPlaylist(initialPlaylistId)
}
}
val allTabLabel = "All Songs"
val tabLabels = listOf(allTabLabel) + filteredPlaylists.map { it.name }
val selectedTabIndex = remember(uiState.selectedPlaylistId, filteredPlaylists) {
if (uiState.selectedPlaylistId == null) {
0
} else {
val idx = filteredPlaylists.indexOfFirst { it.id == uiState.selectedPlaylistId }
if (idx >= 0) idx + 1 else 0
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Library") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (uiState.selectedPlaylistId != null && uiState.songs.isNotEmpty()) {
IconButton(onClick = { viewModel.shufflePlay() }) {
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
}
}
IconButton(onClick = { showSortMenu = !showSortMenu }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
if (tabLabels.size > 1) {
ScrollableTabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier.fillMaxWidth(),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.background,
contentColor = PremiumPurple,
indicator = { tabPositions ->
if (selectedTabIndex < tabPositions.size) {
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = PremiumPurple,
)
}
},
) {
tabLabels.forEachIndexed { index, label ->
Tab(
selected = selectedTabIndex == index,
onClick = {
if (index == 0) {
viewModel.selectPlaylist(null)
} else {
viewModel.selectPlaylist(filteredPlaylists[index - 1].id)
}
},
text = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (selectedTabIndex == index) PremiumPurple else PremiumTextSecondary,
)
},
)
}
}
}
TextField(
value = searchQuery,
onValueChange = {
searchQuery = it
viewModel.search(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search songs & playlists...") },
singleLine = true,
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = PremiumTextSecondary,
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedIndicatorColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedIndicatorColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
shape = RoundedCornerShape(12.dp),
)
AnimatedVisibility(visible = showSortMenu) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SortChip("Date", uiState.songs.isNotEmpty()) {
viewModel.setSortOrder(SortOrder.DATE_SAVED)
showSortMenu = false
}
SortChip("Artist", uiState.songs.isNotEmpty()) {
viewModel.setSortOrder(SortOrder.ARTIST)
showSortMenu = false
}
SortChip("Title", uiState.songs.isNotEmpty()) {
viewModel.setSortOrder(SortOrder.TITLE)
showSortMenu = false
}
}
}
if (uiState.songs.isEmpty() && !uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = PremiumPurple.copy(alpha = 0.3f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (uiState.selectedPlaylistId != null) "Playlist is empty" else "No saved songs yet",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.songs, key = { it.id }) { song ->
SongListItem(
song = song,
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
onDelete = { viewModel.deleteSong(song) },
)
}
}
}
}
}
}
@Composable
private fun SortChip(
label: String,
enabled: Boolean,
onClick: () -> Unit,
) {
FilterChip(
selected = false,
onClick = onClick,
enabled = enabled,
label = { Text(label) },
colors = FilterChipDefaults.filterChipColors(
containerColor = PremiumSurface,
labelColor = PremiumTextSecondary,
disabledContainerColor = PremiumSurfaceVariant,
disabledLabelColor = PremiumTextSecondary.copy(alpha = 0.5f),
),
)
}
@Composable
private fun SongListItem(
song: SavedSongEntity,
isPlaying: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = if (isPlaying) PremiumPurple else PremiumTextSecondary,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = song.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = PremiumText,
)
Text(
text = song.artist,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (song.stationName.isNotEmpty()) {
Text(
text = song.stationName,
style = MaterialTheme.typography.labelSmall,
color = PremiumTextSecondary.copy(alpha = 0.6f),
)
}
}
IconButton(onClick = onToggleFavorite) {
Icon(
if (song.isFavorite) Icons.Default.Favorite
else Icons.Default.FavoriteBorder,
contentDescription = if (song.isFavorite) "Unfavorite" else "Favorite",
tint = if (song.isFavorite) PremiumPurple else PremiumTextSecondary,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = PremiumTextSecondary,
)
}
}
}

View file

@ -0,0 +1,178 @@
package com.rada.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.SortOrder
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val playlistRepository: PlaylistRepository,
private val playbackManager: PlaybackManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(LibraryUiState())
val uiState: StateFlow<LibraryUiState> = _uiState.asStateFlow()
private val searchQuery = MutableStateFlow("")
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val filteredPlaylists: StateFlow<List<PlaylistEntity>> = combine(
allPlaylists, searchQuery
) { playlists, query ->
if (query.isBlank()) playlists
else playlists.filter { it.name.contains(query, ignoreCase = true) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
// flatMapLatest cancels the previous songs flow when the selection
// changes, so picking a playlist immediately switches to the
// playlist-filtered query instead of staying on the full library.
val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId ->
Triple(query, sort, playlistId)
}.flatMapLatest { (query, sort, playlistId) ->
when {
// A playlist selection always wins: query strictly by playlist_id.
playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId)
query.isEmpty() -> libraryRepository.getAll(sort)
else -> libraryRepository.search(query)
}
}
viewModelScope.launch {
songsFlow.collect { songs ->
_uiState.update { it.copy(songs = songs, isLoading = false) }
}
}
}
fun playSong(song: SavedSongEntity) {
playbackManager.playLocalFile(
filePath = song.filePath,
title = song.title,
artist = song.artist,
)
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
}
fun shufflePlay() {
val songs = _uiState.value.songs
if (songs.isEmpty()) return
val shuffled = songs.shuffled(Random)
_uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) }
val first = shuffled.first()
playbackManager.playLocalFile(
filePath = first.filePath,
title = first.title,
artist = first.artist,
)
_uiState.update { it.copy(currentPlayingId = first.id) }
}
fun playNextInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx < 0) return false
val nextIdx = idx + 1
if (nextIdx >= queue.size) return false
val next = queue[nextIdx]
playbackManager.playLocalFile(
filePath = next.filePath,
title = next.title,
artist = next.artist,
)
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) }
return true
}
fun playPreviousInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx <= 0) return false
val prevIdx = idx - 1
val prev = queue[prevIdx]
playbackManager.playLocalFile(
filePath = prev.filePath,
title = prev.title,
artist = prev.artist,
)
_uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) }
return true
}
fun toggleFavorite(songId: Long) {
viewModelScope.launch {
libraryRepository.toggleFavorite(songId)
}
}
fun deleteSong(song: SavedSongEntity) {
viewModelScope.launch {
libraryRepository.delete(song)
try {
val file = java.io.File(song.filePath)
if (file.exists()) file.delete()
} catch (_: Exception) {}
}
}
fun search(query: String) {
searchQuery.value = query
}
fun setSortOrder(order: SortOrder) {
sortOrder.value = order
}
fun clearSearch() {
searchQuery.value = ""
}
fun selectPlaylist(playlistId: Long?) {
selectedPlaylistId.value = playlistId
_uiState.update { it.copy(selectedPlaylistId = playlistId) }
}
fun deletePlaylist(playlist: PlaylistEntity) {
viewModelScope.launch {
playlistRepository.deletePlaylist(playlist)
if (selectedPlaylistId.value == playlist.id) {
selectedPlaylistId.value = null
_uiState.update { it.copy(selectedPlaylistId = null) }
}
}
}
}
data class LibraryUiState(
val songs: List<SavedSongEntity> = emptyList(),
val isLoading: Boolean = true,
val currentPlayingId: Long? = null,
val selectedPlaylistId: Long? = null,
val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1,
) {
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
}

View file

@ -0,0 +1,89 @@
package com.rada.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.rada.ui.library.LibraryScreen
import com.rada.ui.player.NowPlayingBar
import com.rada.ui.player.PlayerScreen
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
@Composable
fun NavGraph(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showNowPlayingBar = currentRoute != Screen.Player.route
Column(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Screen.Player.route,
modifier = Modifier.weight(1f),
) {
composable(Screen.Player.route) {
PlayerScreen(
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
)
}
composable(
route = Screen.Library.route,
arguments = listOf(
navArgument("playlistId") {
type = NavType.LongType
defaultValue = -1L
}
),
) { backStackEntry ->
val playlistId = backStackEntry.arguments?.getLong("playlistId")?.takeIf { it > 0 }
LibraryScreen(
onBack = { navController.popBackStack() },
initialPlaylistId = playlistId,
)
}
composable(Screen.Stations.route) {
StationBrowserScreen(
onBack = { navController.popBackStack() },
onStationSelected = { navController.popBackStack() },
)
}
composable(Screen.Streams.route) {
StreamsScreen(
onBack = { navController.popBackStack() },
onStreamSelected = { navController.popBackStack() },
)
}
composable(Screen.Playlists.route) {
PlaylistManagerScreen(
onBack = { navController.popBackStack() },
onPlaylistSelected = { playlistId ->
navController.navigate(Screen.Library.createRoute(playlistId))
},
)
}
}
if (showNowPlayingBar) {
NowPlayingBar(
onNavigateToPlayer = {
navController.navigate(Screen.Player.route) {
popUpTo(Screen.Player.route) { inclusive = true }
}
},
)
}
}
}

View file

@ -0,0 +1,13 @@
package com.rada.ui.navigation
sealed class Screen(val route: String) {
data object Player : Screen("player")
data object Library : Screen("library?playlistId={playlistId}") {
fun createRoute(playlistId: Long? = null): String {
return if (playlistId != null) "library?playlistId=$playlistId" else "library"
}
}
data object Stations : Screen("stations")
data object Streams : Screen("streams")
data object Playlists : Screen("playlists")
}

View file

@ -0,0 +1,128 @@
package com.rada.ui.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.player.PlaybackManager
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
@Composable
fun NowPlayingBar(
onNavigateToPlayer: () -> Unit,
viewModel: NowPlayingViewModel = hiltViewModel(),
) {
val stationName by viewModel.currentStationName.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
val bufferState by viewModel.bufferState.collectAsState()
val isVisible = stationName.isNotEmpty()
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(PremiumSurface)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onNavigateToPlayer,
),
) {
LinearProgressIndicator(
progress = { when (bufferState) {
PlaybackManager.BufferState.READY -> 1f
PlaybackManager.BufferState.BUFFERING -> 0.5f
PlaybackManager.BufferState.INITIALIZING -> 0.3f
else -> 0f
}},
modifier = Modifier
.fillMaxWidth()
.height(2.dp),
color = PremiumPurple,
trackColor = PremiumSurface,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = PremiumPurple,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier
.weight(1f)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onNavigateToPlayer,
),
) {
Text(
text = stationName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = PremiumText,
)
}
IconButton(
onClick = { viewModel.togglePlayPause() },
modifier = Modifier.size(44.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = PremiumPurple,
modifier = Modifier.size(28.dp),
)
}
}
}
}
}

View file

@ -0,0 +1,22 @@
package com.rada.ui.player
import androidx.lifecycle.ViewModel
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class NowPlayingViewModel @Inject constructor(
private val playbackManager: PlaybackManager,
) : ViewModel() {
val currentStationName: StateFlow<String> = playbackManager.currentStationNameFlow
val currentStationUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
fun togglePlayPause() {
playbackManager.togglePlayPause()
}
}

View file

@ -0,0 +1,801 @@
package com.rada.ui.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Podcasts
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import com.rada.ui.theme.GradientEnd
import com.rada.ui.theme.GradientMid
import com.rada.ui.theme.GradientStart
import com.rada.ui.theme.PremiumCyan
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
import com.rada.ui.theme.PremiumBorder
@Composable
fun PlayerScreen(
onNavigateToStations: () -> Unit,
onNavigateToLibrary: () -> Unit,
onNavigateToStreams: () -> Unit,
onNavigateToPlaylists: () -> Unit,
viewModel: PlayerViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val playlists by viewModel.playlists.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
// Sync state when screen becomes visible (e.g. returning from Stations)
LaunchedEffect(Unit) {
viewModel.syncPlaybackState()
}
LaunchedEffect(uiState.error) {
uiState.error?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
LaunchedEffect(uiState.playlistPickerMessage) {
uiState.playlistPickerMessage?.let { msg ->
snackbarHostState.showSnackbar(msg)
viewModel.clearPlaylistPickerMessage()
}
}
LaunchedEffect(uiState.saveState) {
val result = uiState.lastSaveResult
if (result != null && uiState.saveState == PlaybackManager.SaveState.SUCCESS) {
viewModel.clearSaveResult()
} else if (result != null && uiState.saveState == PlaybackManager.SaveState.ERROR) {
snackbarHostState.showSnackbar(result.error ?: "Save failed")
viewModel.clearSaveResult()
}
}
if (uiState.showPlaylistPicker) {
PlaylistPickerDialog(
playlists = playlists,
newPlaylistName = uiState.newPlaylistName,
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
onPlaylistSelected = viewModel::addToPlaylist,
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
onDismiss = viewModel::dismissPlaylistPicker,
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
BottomAppBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
NavigationBarItem(
selected = false,
onClick = onNavigateToStations,
icon = { Icon(Icons.Default.Radio, contentDescription = "Stations") },
label = { Text("Stations") },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
NavigationBarItem(
selected = false,
onClick = onNavigateToLibrary,
icon = { Icon(Icons.Default.LibraryMusic, contentDescription = "Library") },
label = { Text("Library") },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
NavigationBarItem(
selected = false,
onClick = onNavigateToStreams,
icon = { Icon(Icons.Default.Podcasts, contentDescription = "Streams") },
label = { Text("Streams") },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
NavigationBarItem(
selected = false,
onClick = onNavigateToPlaylists,
icon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = "Playlists") },
label = { Text("Playlists") },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
}
},
) { padding ->
if (uiState.currentStation != null) {
NowPlayingView(
uiState = uiState,
viewModel = viewModel,
modifier = Modifier.padding(padding),
)
} else {
EmptyStateView(
onBrowseStations = onNavigateToStations,
onImportMusic = onNavigateToLibrary,
modifier = Modifier.padding(padding),
)
}
}
}
@Composable
private fun EmptyStateView(
onBrowseStations: () -> Unit,
onImportMusic: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
WaveGraphic(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
)
Spacer(modifier = Modifier.height(40.dp))
Text(
text = "Find your sound",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = PremiumText,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Choose a station or add music\nto start listening",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
textAlign = TextAlign.Center,
lineHeight = 24.sp,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = onBrowseStations,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PremiumPurple,
),
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Browse stations",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onImportMusic,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = PremiumPurpleLight,
),
) {
Icon(
Icons.Default.LibraryMusic,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Import music",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun WaveGraphic(modifier: Modifier = Modifier) {
val gradientColors = listOf(GradientStart, GradientMid, GradientEnd)
Canvas(modifier = modifier) {
val width = size.width
val height = size.height
val centerY = height / 2
// Draw multiple wave layers
for (layer in 0..2) {
val path = Path()
val amplitude = (30f - layer * 8f) * (width / 400f)
val frequency = 0.008f + layer * 0.002f
val phaseShift = layer * 0.5f
path.moveTo(0f, centerY)
for (x in 0..width.toInt()) {
val xFloat = x.toFloat()
val y = centerY + amplitude * kotlin.math.sin(
(xFloat * frequency * Math.PI * 2).toDouble() + phaseShift
).toFloat()
if (x == 0) {
path.moveTo(xFloat, y)
} else {
path.lineTo(xFloat, y)
}
}
drawPath(
path = path,
brush = Brush.horizontalGradient(
colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) },
startX = 0f,
endX = width,
),
style = Stroke(width = (3f - layer * 0.5f) * (width / 400f)),
)
}
// Draw subtle glow at center
drawCircle(
brush = Brush.radialGradient(
colors = listOf(PremiumPurple.copy(alpha = 0.15f), Color.Transparent),
center = Offset(width / 2, centerY),
radius = width / 3,
),
radius = width / 3,
center = Offset(width / 2, centerY),
)
}
}
@Composable
private fun NowPlayingView(
uiState: PlayerUiState,
viewModel: PlayerViewModel,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Station card
uiState.currentStation?.let { station ->
StationCard(
station = station,
bufferState = uiState.bufferState,
)
}
Spacer(modifier = Modifier.height(32.dp))
// Artist/Station name
AnimatedContent(
targetState = uiState.artist,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "artist",
) { artist ->
Text(
text = artist.ifEmpty { "No station" },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// Song title
AnimatedContent(
targetState = uiState.title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "title",
) { title ->
if (title.isNotEmpty()) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
} else if (uiState.isPlaying && uiState.currentStation != null) {
Text(
text = if (uiState.hasMetadata) "" else "No metadata",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(48.dp))
// Save button
SaveSongButton(
canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY,
saveState = uiState.saveState,
onClick = { viewModel.saveSong() },
)
Spacer(modifier = Modifier.height(32.dp))
// Playback controls
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { viewModel.previousStation() },
enabled = uiState.currentStation != null,
) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = "Previous",
modifier = Modifier.size(32.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.stop() },
enabled = uiState.currentStation != null,
) {
Icon(
Icons.Default.Stop,
contentDescription = "Stop",
modifier = Modifier.size(32.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = { viewModel.togglePlayPause() },
enabled = uiState.currentStation != null,
modifier = Modifier.size(72.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (uiState.isPlaying) {
Icon(
Icons.Default.Pause,
contentDescription = "Pause",
modifier = Modifier.size(36.dp),
)
} else {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play",
modifier = Modifier.size(36.dp),
)
}
}
}
}
}
@Composable
private fun PlaylistPickerDialog(
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
newPlaylistName: String,
onNewPlaylistNameChange: (String) -> Unit,
onPlaylistSelected: (Long) -> Unit,
onCreatePlaylist: () -> Unit,
onDismiss: () -> Unit,
) {
var showNewPlaylistInput by remember { mutableStateOf(false) }
var playlistSearchQuery by remember { mutableStateOf("") }
val filteredPlaylists = remember(playlists, playlistSearchQuery) {
if (playlistSearchQuery.isBlank()) playlists
else playlists.filter { it.name.contains(playlistSearchQuery, ignoreCase = true) }
}
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Add to Playlist") },
text = {
Column {
if (playlists.isNotEmpty()) {
OutlinedTextField(
value = playlistSearchQuery,
onValueChange = { playlistSearchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search playlists...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(8.dp))
}
if (filteredPlaylists.isNotEmpty()) {
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredPlaylists) { playlist ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPlaylistSelected(playlist.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = playlist.name,
style = MaterialTheme.typography.bodyLarge,
color = PremiumText,
)
}
}
}
} else if (playlists.isNotEmpty()) {
Text(
text = "No matching playlists",
style = MaterialTheme.typography.bodyMedium,
color = PremiumTextSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
Text(
text = "No playlists yet. Create one below.",
style = MaterialTheme.typography.bodyMedium,
color = PremiumTextSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
if (showNewPlaylistInput) {
OutlinedTextField(
value = newPlaylistName,
onValueChange = onNewPlaylistNameChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Playlist name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
if (newPlaylistName.isNotBlank()) {
onCreatePlaylist()
showNewPlaylistInput = false
playlistSearchQuery = ""
}
},
enabled = newPlaylistName.isNotBlank(),
) {
Text("Create & Add")
}
} else {
TextButton(
onClick = { showNewPlaylistInput = true },
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("New Playlist")
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun SaveSongButton(
canSave: Boolean,
saveState: PlaybackManager.SaveState,
onClick: () -> Unit,
) {
val containerColor = when {
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
saveState == PlaybackManager.SaveState.ERROR -> MaterialTheme.colorScheme.error
canSave -> PremiumPurple
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when {
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.onSecondary
saveState == PlaybackManager.SaveState.SUCCESS -> Color.White
saveState == PlaybackManager.SaveState.ERROR -> MaterialTheme.colorScheme.onError
canSave -> Color.White
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
FilledIconButton(
onClick = onClick,
enabled = canSave && saveState != PlaybackManager.SaveState.SAVING,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
shape = RoundedCornerShape(20.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.5f),
disabledContentColor = contentColor.copy(alpha = 0.5f),
),
) {
AnimatedContent(
targetState = saveState,
transitionSpec = {
scaleIn(initialScale = 0.8f) + fadeIn() togetherWith
scaleOut(targetScale = 0.8f) + fadeOut()
},
label = "saveIcon",
) { state ->
when (state) {
PlaybackManager.SaveState.SAVING -> {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = contentColor,
strokeWidth = 3.dp,
)
}
PlaybackManager.SaveState.SUCCESS -> {
Icon(
Icons.Default.Check,
contentDescription = "Saved",
modifier = Modifier.size(36.dp),
)
}
PlaybackManager.SaveState.ERROR -> {
Icon(
Icons.Default.Error,
contentDescription = "Error",
modifier = Modifier.size(36.dp),
)
}
PlaybackManager.SaveState.IDLE -> {
Icon(
Icons.Default.Save,
contentDescription = "Save Song",
modifier = Modifier.size(36.dp),
)
}
}
}
}
}
@Composable
private fun StationCard(
station: Station,
bufferState: PlaybackManager.BufferState,
) {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
BufferIndicator(bufferState = bufferState)
}
}
}
@Composable
private fun BufferIndicator(bufferState: PlaybackManager.BufferState) {
val (text, color) = when (bufferState) {
PlaybackManager.BufferState.IDLE -> "" to MaterialTheme.colorScheme.onSurfaceVariant
PlaybackManager.BufferState.INITIALIZING -> "Init" to PremiumCyan
PlaybackManager.BufferState.BUFFERING -> "Buffering" to PremiumCyan
PlaybackManager.BufferState.READY -> "Ready" to MaterialTheme.colorScheme.primary
PlaybackManager.BufferState.ERROR -> "Error" to MaterialTheme.colorScheme.error
}
if (text.isNotEmpty()) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View file

@ -0,0 +1,262 @@
package com.rada.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.StationRepository
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import com.rada.recording.SaveSongUseCase
import com.rada.recording.StreamMetadata
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val playbackManager: PlaybackManager,
private val stationRepository: StationRepository,
private val playlistRepository: PlaylistRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
val playlists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val playbackListener = object : PlaybackManager.PlaybackListener {
override fun onPlaybackStateChanged(isPlaying: Boolean) {
_uiState.update { it.copy(isPlaying = isPlaying) }
}
override fun onMetadataUpdate(artist: String, title: String) {
_uiState.update {
it.copy(
artist = artist,
title = title,
hasMetadata = artist.isNotEmpty() || title.isNotEmpty(),
)
}
}
override fun onError(message: String) {
_uiState.update { it.copy(error = message) }
}
override fun onIcyMetadata(metadata: StreamMetadata) {
_uiState.update {
it.copy(
lastMetadata = metadata,
hasMetadata = metadata.artist.isNotEmpty() || metadata.title.isNotEmpty(),
)
}
}
override fun onBufferStateChanged(state: PlaybackManager.BufferState) {
_uiState.update { it.copy(bufferState = state) }
}
override fun onBufferBytesWritten(totalBytes: Long) {
_uiState.update { it.copy(bufferBytesWritten = totalBytes) }
}
override fun onSaveStateChanged(state: PlaybackManager.SaveState, result: SaveSongUseCase.SaveResult?) {
_uiState.update {
it.copy(
saveState = state,
lastSaveResult = result,
)
}
if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) {
_uiState.update {
it.copy(
showPlaylistPicker = true,
pickedSongId = result.songId,
)
}
}
}
override fun onStationChanged(name: String, url: String) {
val station = stationRepository.getAll().find { it.url == url && it.name == name }
_uiState.update {
it.copy(
currentStation = station ?: Station(id = url, name = name, url = url),
artist = name,
title = "",
hasMetadata = false,
lastMetadata = null,
error = null,
)
}
}
}
init {
playbackManager.listener = playbackListener
_uiState.update {
it.copy(
stations = stationRepository.getAll(),
currentStation = playbackManager.currentStationUrl?.let { url ->
Station(id = url, name = playbackManager.currentStationName, url = url)
},
isPlaying = playbackManager.isPlaying,
)
}
}
fun playStation(station: Station) {
_uiState.update {
it.copy(
currentStation = station,
artist = station.name,
title = "",
hasMetadata = false,
lastMetadata = null,
)
}
playbackManager.playStation(station.url, station.name)
}
fun togglePlayPause() {
playbackManager.togglePlayPause()
}
fun stop() {
playbackManager.stop()
_uiState.update {
it.copy(
artist = it.currentStation?.name ?: "",
title = "",
hasMetadata = false,
lastMetadata = null,
)
}
}
fun nextStation() {
val stations = _uiState.value.stations
if (stations.isEmpty()) return
val current = _uiState.value.currentStation
val idx = stations.indexOfFirst { it.id == current?.id }
val next = if (idx < 0 || idx >= stations.lastIndex) stations.first() else stations[idx + 1]
playStation(next)
}
fun previousStation() {
val stations = _uiState.value.stations
if (stations.isEmpty()) return
val current = _uiState.value.currentStation
val idx = stations.indexOfFirst { it.id == current?.id }
val prev = if (idx <= 0) stations.last() else stations[idx - 1]
playStation(prev)
}
fun saveSong() {
playbackManager.saveCurrentSong()
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
fun clearSaveResult() {
_uiState.update { it.copy(lastSaveResult = null) }
}
fun dismissPlaylistPicker() {
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
newPlaylistName = "",
)
}
}
fun addToPlaylist(playlistId: Long) {
val songId = _uiState.value.pickedSongId ?: return
viewModelScope.launch {
playlistRepository.addSongToPlaylist(playlistId, songId)
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
newPlaylistName = "",
playlistPickerMessage = "Added to playlist",
)
}
}
}
fun createPlaylistAndAdd(name: String) {
val songId = _uiState.value.pickedSongId ?: return
viewModelScope.launch {
val playlistId = playlistRepository.createPlaylist(name)
playlistRepository.addSongToPlaylist(playlistId, songId)
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
newPlaylistName = "",
playlistPickerMessage = "Created \"$name\" and added song",
)
}
}
}
fun updateNewPlaylistName(name: String) {
_uiState.update { it.copy(newPlaylistName = name) }
}
fun clearPlaylistPickerMessage() {
_uiState.update { it.copy(playlistPickerMessage = null) }
}
fun syncPlaybackState() {
playbackManager.listener = playbackListener
val url = playbackManager.currentStationUrl
_uiState.update {
it.copy(
stations = stationRepository.getAll(),
currentStation = url?.let { u ->
Station(id = u, name = playbackManager.currentStationName, url = u)
},
isPlaying = playbackManager.isPlaying,
artist = playbackManager.currentStationName,
)
}
}
override fun onCleared() {
super.onCleared()
playbackManager.listener = null
}
}
data class PlayerUiState(
val currentStation: Station? = null,
val isPlaying: Boolean = false,
val artist: String = "",
val title: String = "",
val hasMetadata: Boolean = false,
val lastMetadata: StreamMetadata? = null,
val error: String? = null,
val stations: List<Station> = emptyList(),
val bufferState: PlaybackManager.BufferState = PlaybackManager.BufferState.IDLE,
val bufferBytesWritten: Long = 0,
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
val showPlaylistPicker: Boolean = false,
val pickedSongId: Long? = null,
val newPlaylistName: String = "",
val playlistPickerMessage: String? = null,
)

View file

@ -0,0 +1,399 @@
package com.rada.ui.playlists
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.local.entity.PlaylistEntity
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistManagerScreen(
onBack: () -> Unit,
onPlaylistSelected: (Long) -> Unit = {},
viewModel: PlaylistManagerViewModel = hiltViewModel(),
) {
val playlists by viewModel.playlists.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val sortOrder by viewModel.sortOrder.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showSortMenu by remember { mutableStateOf(false) }
var showCreateDialog by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<PlaylistEntity?>(null) }
var deleteTarget by remember { mutableStateOf<PlaylistEntity?>(null) }
LaunchedEffect(uiState.message) {
uiState.message?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearMessage()
}
}
if (showCreateDialog) {
CreatePlaylistDialog(
onDismiss = { showCreateDialog = false },
onCreate = { name ->
viewModel.createPlaylist(name)
showCreateDialog = false
},
)
}
if (showRenameDialog && renameTarget != null) {
RenamePlaylistDialog(
currentName = renameTarget!!.name,
onDismiss = { showRenameDialog = false },
onRename = { newName ->
viewModel.renamePlaylist(renameTarget!!, newName)
showRenameDialog = false
},
)
}
if (showDeleteDialog && deleteTarget != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Playlist") },
text = { Text("Delete \"${deleteTarget!!.name}\"? Songs won't be deleted.") },
confirmButton = {
TextButton(onClick = {
viewModel.deletePlaylist(deleteTarget!!)
showDeleteDialog = false
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Playlists") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showSortMenu = !showSortMenu }) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort")
}
IconButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "New Playlist")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search playlists...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
shape = RoundedCornerShape(12.dp),
)
Box {
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false },
) {
DropdownMenuItem(
text = { Text("Name A–Z") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_ASC); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_ASC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
)
DropdownMenuItem(
text = { Text("Name Z–A") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.NAME_DESC); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.NAME_DESC) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
)
DropdownMenuItem(
text = { Text("Newest first") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_NEWEST); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_NEWEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
)
DropdownMenuItem(
text = { Text("Oldest first") },
onClick = { viewModel.setSortOrder(PlaylistSortOrder.DATE_OLDEST); showSortMenu = false },
leadingIcon = { if (sortOrder == PlaylistSortOrder.DATE_OLDEST) Icon(Icons.Default.Search, null, tint = PremiumPurple) else null },
)
}
}
if (playlists.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.AutoMirrored.Filled.PlaylistPlay,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = PremiumPurple.copy(alpha = 0.3f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (searchQuery.isNotBlank()) "No matching playlists" else "No playlists yet",
style = MaterialTheme.typography.bodyLarge,
color = PremiumTextSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = { showCreateDialog = true },
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Create Playlist")
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(playlists, key = { it.id }) { playlist ->
PlaylistListItem(
playlist = playlist,
onClick = { onPlaylistSelected(playlist.id) },
onRename = {
renameTarget = playlist
showRenameDialog = true
},
onDelete = {
deleteTarget = playlist
showDeleteDialog = true
},
)
}
}
}
}
}
}
@Composable
private fun PlaylistListItem(
playlist: PlaylistEntity,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.AutoMirrored.Filled.PlaylistPlay,
contentDescription = null,
tint = PremiumPurple,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = playlist.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
)
}
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun CreatePlaylistDialog(
onDismiss: () -> Unit,
onCreate: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Playlist") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Playlist name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
},
confirmButton = {
TextButton(
onClick = { onCreate(name) },
enabled = name.isNotBlank(),
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun RenamePlaylistDialog(
currentName: String,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
) {
var name by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename Playlist") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("New name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
},
confirmButton = {
TextButton(
onClick = { onRename(name) },
enabled = name.isNotBlank() && name != currentName,
) {
Text("Rename")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}

View file

@ -0,0 +1,88 @@
package com.rada.ui.playlists
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.repository.PlaylistRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class PlaylistSortOrder { NAME_ASC, NAME_DESC, DATE_NEWEST, DATE_OLDEST }
@HiltViewModel
class PlaylistManagerViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlaylistManagerUiState())
val uiState: StateFlow<PlaylistManagerUiState> = _uiState.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _sortOrder = MutableStateFlow(PlaylistSortOrder.NAME_ASC)
val sortOrder: StateFlow<PlaylistSortOrder> = _sortOrder.asStateFlow()
private val allPlaylists = playlistRepository.getAllPlaylists()
val playlists: StateFlow<List<PlaylistEntity>> = combine(
allPlaylists, _searchQuery, _sortOrder,
) { list, query, sort ->
val filtered = if (query.isBlank()) list
else list.filter { it.name.contains(query, ignoreCase = true) }
when (sort) {
PlaylistSortOrder.NAME_ASC -> filtered.sortedBy { it.name.lowercase() }
PlaylistSortOrder.NAME_DESC -> filtered.sortedByDescending { it.name.lowercase() }
PlaylistSortOrder.DATE_NEWEST -> filtered.sortedByDescending { it.id }
PlaylistSortOrder.DATE_OLDEST -> filtered.sortedBy { it.id }
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun search(query: String) {
_searchQuery.value = query
}
fun setSortOrder(order: PlaylistSortOrder) {
_sortOrder.value = order
}
fun createPlaylist(name: String) {
if (name.isBlank()) return
viewModelScope.launch {
playlistRepository.createPlaylist(name.trim())
_uiState.update { it.copy(message = "Created \"$name\"") }
}
}
fun renamePlaylist(playlist: PlaylistEntity, newName: String) {
if (newName.isBlank()) return
viewModelScope.launch {
playlistRepository.deletePlaylist(playlist)
playlistRepository.createPlaylist(newName.trim())
_uiState.update { it.copy(message = "Renamed to \"$newName\"") }
}
}
fun deletePlaylist(playlist: PlaylistEntity) {
viewModelScope.launch {
playlistRepository.deletePlaylist(playlist)
_uiState.update { it.copy(message = "Deleted \"${playlist.name}\"") }
}
}
fun clearMessage() {
_uiState.update { it.copy(message = null) }
}
}
data class PlaylistManagerUiState(
val message: String? = null,
)

View file

@ -0,0 +1,441 @@
package com.rada.ui.stations
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
private val genreColors = mapOf(
"Lofi" to 0xFF7C3AED,
"Synthwave" to 0xFFEC4899,
"EDM" to 0xFF06B6D4,
"Phonk" to 0xFFEF4444,
"Classical" to 0xFFF59E0B,
"Strings" to 0xFF10B981,
"Ambient" to 0xFF3B82F6,
"Electronic" to 0xFF06B6D4,
"Jazz" to 0xFFF59E0B,
"Eclectic" to 0xFFEC4899,
"Oldies" to 0xFFF59E0B,
"Rock" to 0xFFEF4444,
"Hip-Hop" to 0xFFEC4899,
"Custom" to 0xFF7C3AED,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StationBrowserScreen(
onBack: () -> Unit,
onStationSelected: () -> Unit = {},
viewModel: StationBrowserViewModel = hiltViewModel(),
) {
val stationsByGenre by viewModel.stationsByGenre.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<Station?>(null) }
if (showAddDialog) {
AddStationDialog(
onDismiss = { showAddDialog = false },
onAdd = { name, url ->
viewModel.addCustomStation(name, url)
showAddDialog = false
},
)
}
renameTarget?.let { target ->
RenameStationDialog(
station = target,
isRenamed = viewModel.isRenamed(target.id),
originalName = viewModel.getOriginalStationName(target.id),
onDismiss = { renameTarget = null },
onRename = { newName ->
viewModel.renameStation(target.id, newName)
renameTarget = null
},
onReset = {
viewModel.resetStationName(target.id)
renameTarget = null
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Stations") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add station URL")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search stations...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
shape = RoundedCornerShape(12.dp),
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
stationsByGenre.forEach { (genre, stations) ->
item {
GenreHeader(genre = genre, count = stations.size)
}
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
onClick = {
viewModel.selectStation(station)
onStationSelected()
},
onRename = { renameTarget = station },
onDelete = {
viewModel.removeCustomStation(station.id)
},
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
}
@Composable
private fun AddStationDialog(
onDismiss: () -> Unit,
onAdd: (name: String, url: String) -> Unit,
) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Add Station URL") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Station name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Stream URL") },
placeholder = { Text("https://...") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
}
},
confirmButton = {
TextButton(
onClick = { onAdd(name, url) },
enabled = name.isNotBlank() && url.isNotBlank(),
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun RenameStationDialog(
station: Station,
isRenamed: Boolean,
originalName: String?,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onReset: () -> Unit,
) {
var name by remember(station.id) { mutableStateOf(station.name) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Rename Station") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Station name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
if (isRenamed && originalName != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Renamed from \"$originalName\"",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
)
}
}
},
confirmButton = {
TextButton(
onClick = { onRename(name) },
enabled = name.isNotBlank() && name != station.name,
) {
Text("Rename")
}
},
dismissButton = {
Row {
if (isRenamed) {
TextButton(onClick = onReset) {
Text("Reset")
}
}
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
},
)
}
@Composable
private fun GenreHeader(genre: String, count: Int) {
val colorInt = genreColors.entries.find { (key, _) ->
genre.contains(key, ignoreCase = true)
}?.value ?: 0xFF7C3AED
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(androidx.compose.ui.graphics.Color(colorInt)),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = genre.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color(colorInt),
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = PremiumTextSecondary,
)
}
}
@Composable
private fun StationCard(
station: Station,
isCustom: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = PremiumSurfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(PremiumPurple.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = PremiumPurpleLight,
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
)
}
}
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
modifier = Modifier.size(20.dp),
)
}
if (isCustom) {
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = PremiumTextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}

View file

@ -0,0 +1,95 @@
package com.rada.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.repository.StationRepository
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
@HiltViewModel
class StationBrowserViewModel @Inject constructor(
private val stationRepository: StationRepository,
private val playbackManager: PlaybackManager,
) : ViewModel() {
private val _allStations = MutableStateFlow(stationRepository.getAll())
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
val filtered = if (query.isBlank()) list
else list.filter {
it.name.contains(query, ignoreCase = true) ||
it.genre.contains(query, ignoreCase = true) ||
it.url.contains(query, ignoreCase = true)
}
filtered.sortedBy { it.name.lowercase() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), stationRepository.getAll())
val stationsByGenre: StateFlow<Map<String, List<Station>>> = combine(_allStations, _searchQuery) { list, query ->
val filtered = if (query.isBlank()) list
else list.filter {
it.name.contains(query, ignoreCase = true) ||
it.genre.contains(query, ignoreCase = true) ||
it.url.contains(query, ignoreCase = true)
}
filtered.sortedBy { it.name.lowercase() }
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
.toSortedMap(compareBy { it.lowercase() })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
fun search(query: String) {
_searchQuery.value = query
}
fun selectStation(station: Station) {
playbackManager.playStation(station.url, station.name)
}
fun addCustomStation(name: String, url: String) {
if (name.isBlank() || url.isBlank()) return
val id = "custom-${System.currentTimeMillis()}"
val station = Station(
id = id,
name = name.trim(),
url = url.trim(),
genre = "Custom",
)
stationRepository.addStation(station)
_allStations.value = stationRepository.getAll()
}
fun removeCustomStation(id: String) {
stationRepository.removeStation(id)
_allStations.value = stationRepository.getAll()
}
fun renameStation(id: String, newName: String) {
stationRepository.renameStation(id, newName)
_allStations.value = stationRepository.getAll()
}
fun resetStationName(id: String) {
stationRepository.resetStationName(id)
_allStations.value = stationRepository.getAll()
}
fun isRenamed(id: String): Boolean = stationRepository.isRenamed(id)
fun getOriginalStationName(id: String): String? =
stationRepository.getOriginalStationName(id)
fun isCustomStation(id: String): Boolean {
return stationRepository.isCustomStation(id)
}
}

View file

@ -0,0 +1,460 @@
package com.rada.ui.streams
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.StreamEntry
import com.rada.ui.theme.PremiumBorder
import com.rada.ui.theme.PremiumPurple
import com.rada.ui.theme.PremiumPurpleLight
import com.rada.ui.theme.PremiumSurface
import com.rada.ui.theme.PremiumSurfaceVariant
import com.rada.ui.theme.PremiumText
import com.rada.ui.theme.PremiumTextSecondary
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StreamsScreen(
onBack: () -> Unit,
onStreamSelected: () -> Unit = {},
viewModel: StreamsViewModel = hiltViewModel(),
) {
val groupedEntries by viewModel.groupedEntries.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isFetching by viewModel.isFetching.collectAsState()
val fetchStatus by viewModel.fetchStatus.collectAsState()
val error by viewModel.error.collectAsState()
val totalCount by viewModel.totalCount.collectAsState()
val currentStreamUrl by viewModel.currentStreamUrl.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<StreamEntry?>(null) }
if (showAddDialog) {
AddStreamDialog(
onDismiss = { showAddDialog = false },
onAdd = { name, url ->
viewModel.addStream(url)
showAddDialog = false
},
)
}
renameTarget?.let { target ->
RenameStreamDialog(
entry = target,
onDismiss = { renameTarget = null },
onRename = { newName ->
viewModel.rename(target.id, newName)
renameTarget = null
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Streams") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add stream URL")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.search(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search streams...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = "Search")
},
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = PremiumSurface,
unfocusedContainerColor = PremiumSurface,
focusedBorderColor = PremiumPurple.copy(alpha = 0.5f),
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
shape = RoundedCornerShape(12.dp),
)
// Status / error row
val statusText = error ?: fetchStatus
if (statusText.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isFetching) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = PremiumPurple,
)
Spacer(modifier = Modifier.width(10.dp))
}
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (error != null) Color(0xFFEF4444) else PremiumTextSecondary,
)
}
}
if (totalCount == 0 && !isFetching) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = PremiumTextSecondary,
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No streams yet",
style = MaterialTheme.typography.titleMedium,
color = PremiumText,
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Tap + to add a stream URL or an .m3u playlist",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
groupedEntries.forEach { (group, entries) ->
item(key = "header-$group") {
GroupHeader(group = group, count = entries.size)
}
items(entries, key = { it.id }) { entry ->
StreamCard(
entry = entry,
isPlaying = currentStreamUrl == entry.url,
onClick = {
viewModel.play(entry)
onStreamSelected()
},
onRename = { renameTarget = entry },
onDelete = { viewModel.remove(entry.id) },
)
}
item(key = "spacer-$group") {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
}
}
@Composable
private fun AddStreamDialog(
onDismiss: () -> Unit,
onAdd: (name: String, url: String) -> Unit,
) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Add Stream") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Stream URL or playlist (.m3u / .m3u8 / .pls)") },
placeholder = { Text("https://example.com/stream.m3u") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Display name (optional)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Paste a direct stream URL, or an .m3u/.pls playlist to add all of its streams.",
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
)
}
},
confirmButton = {
TextButton(
onClick = { onAdd(name, url) },
enabled = url.isNotBlank(),
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun RenameStreamDialog(
entry: StreamEntry,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
) {
var name by remember(entry.id) { mutableStateOf(entry.name) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Rename Stream") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Display name") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PremiumPurple,
unfocusedBorderColor = PremiumBorder,
focusedTextColor = PremiumText,
unfocusedTextColor = PremiumText,
),
)
},
confirmButton = {
TextButton(
onClick = { onRename(name) },
enabled = name.isNotBlank() && name != entry.name,
) {
Text("Rename")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun GroupHeader(group: String, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(PremiumPurple),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = group.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = PremiumPurpleLight,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = PremiumTextSecondary,
)
}
}
@Composable
private fun StreamCard(
entry: StreamEntry,
isPlaying: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isPlaying) PremiumPurple.copy(alpha = 0.12f) else PremiumSurfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isPlaying) PremiumPurple else PremiumPurple.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
tint = if (isPlaying) Color.White else PremiumPurpleLight,
modifier = Modifier.size(22.dp),
)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = PremiumText,
)
Text(
text = entry.url,
style = MaterialTheme.typography.bodySmall,
color = PremiumTextSecondary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = PremiumTextSecondary,
modifier = Modifier.size(20.dp),
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = PremiumTextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
}

View file

@ -0,0 +1,119 @@
package com.rada.ui.streams
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.repository.StreamFetcher
import com.rada.data.repository.StreamRepository
import com.rada.domain.model.StreamEntry
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StreamsViewModel @Inject constructor(
private val streamRepository: StreamRepository,
private val streamFetcher: StreamFetcher,
private val playbackManager: PlaybackManager
) : ViewModel() {
private val _allEntries = MutableStateFlow(streamRepository.getAll())
private val _searchQuery = MutableStateFlow("")
private val _isFetching = MutableStateFlow(false)
private val _fetchStatus = MutableStateFlow("")
private val _error = MutableStateFlow<String?>(null)
val isFetching: StateFlow<Boolean> = _isFetching.asStateFlow()
val fetchStatus: StateFlow<String> = _fetchStatus.asStateFlow()
val error: StateFlow<String?> = _error.asStateFlow()
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
/** URL currently playing (reactive) — drives the play/pause highlight. */
val currentStreamUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
/** Grouped, search-filtered entries: group name -> entries (sorted). */
val groupedEntries: StateFlow<Map<String, List<StreamEntry>>> =
combine(_allEntries, _searchQuery) { list, query ->
groupAndFilter(list, query)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private fun groupAndFilter(list: List<StreamEntry>, query: String): Map<String, List<StreamEntry>> {
val filtered = if (query.isBlank()) list else list.filter {
it.name.contains(query, true) || it.url.contains(query, true) || it.group.contains(query, true)
}
return filtered
.groupBy { it.group }
.mapValues { (_, entries) -> entries.sortedBy { e -> e.name.lowercase() } }
.toSortedMap(compareBy { it.lowercase() })
}
/** Total number of entries currently stored. */
val totalCount: StateFlow<Int> = _allEntries.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
fun search(query: String) { _searchQuery.value = query }
/**
* Add a stream URL (or m3u / pls playlist URL). Fetches + parses it,
* merges the resulting entries into the library, and reports the result.
*/
fun addStream(url: String) {
if (url.isBlank() || _isFetching.value) return
viewModelScope.launch {
_error.value = null
_isFetching.value = true
_fetchStatus.value = "Fetching…"
try {
val entries = streamFetcher.fetch(url)
if (entries.isEmpty()) {
_fetchStatus.value = ""
_error.value = "No streams found at that URL"
} else {
streamRepository.addAll(entries)
_allEntries.value = streamRepository.getAll()
_fetchStatus.value = "Added ${entries.size} stream${if (entries.size == 1) "" else "s"}"
}
} catch (e: Exception) {
_fetchStatus.value = ""
_error.value = "Couldn't fetch: ${e.message ?: "unknown error"}"
} finally {
_isFetching.value = false
}
}
}
fun play(entry: StreamEntry) {
playbackManager.playStation(entry.url, entry.name)
}
fun stop() {
playbackManager.stop()
}
fun isPlayingUrl(url: String): Boolean {
return playbackManager.currentStationUrl == url
}
fun remove(id: String) {
streamRepository.remove(id)
_allEntries.value = streamRepository.getAll()
}
fun rename(id: String, newName: String) {
streamRepository.rename(id, newName)
_allEntries.value = streamRepository.getAll()
}
fun clearAll() {
streamRepository.clear()
_allEntries.value = streamRepository.getAll()
}
}

View file

@ -0,0 +1,56 @@
package com.rada.ui.theme
import androidx.compose.ui.graphics.Color
// ── Premium dark palette ──
val PremiumBlack = Color(0xFF0A0A14)
val PremiumDark = Color(0xFF12121E)
val PremiumSurface = Color(0xFF1A1A2E)
val PremiumSurfaceVariant = Color(0xFF222238)
val PremiumBorder = Color(0xFF2A2A44)
val PremiumPurple = Color(0xFF7C3AED)
val PremiumPurpleLight = Color(0xFFA78BFA)
val PremiumBlue = Color(0xFF3B82F6)
val PremiumCyan = Color(0xFF06B6D4)
val PremiumPink = Color(0xFFEC4899)
val PremiumText = Color(0xFFF0F0F5)
val PremiumTextSecondary = Color(0xFF9CA3AF)
val PremiumTextMuted = Color(0xFF6B7280)
val PremiumSuccess = Color(0xFF10B981)
val PremiumError = Color(0xFFEF4444)
val PremiumWarning = Color(0xFFF59E0B)
// Gradient colors for accents
val GradientStart = Color(0xFF7C3AED)
val GradientMid = Color(0xFF3B82F6)
val GradientEnd = Color(0xFF06B6D4)
// Legacy terminal palette (cliamp-inspired)
val TerminalBlack = Color(0xFF000000)
val TerminalDarkGray = Color(0xFF0A0A0A)
val TerminalGray = Color(0xFF1A1A1A)
val TerminalMidGray = Color(0xFF2A2A2A)
val TerminalGreen = Color(0xFF00FF41)
val TerminalGreenDim = Color(0xFF00CC33)
val TerminalGreenMuted = Color(0xFF007A1F)
val TerminalGold = Color(0xFFFFD700)
val TerminalGoldDim = Color(0xFFCC9900)
val TerminalWhite = Color(0xFFE0E0E0)
val TerminalLightGray = Color(0xFFAAAAAA)
val TerminalDarkText = Color(0xFF666666)
val TerminalRed = Color(0xFFFF5555)
val TerminalCyan = Color(0xFF00D4FF)
val TerminalMagenta = Color(0xFFFF55FF)
// Legacy aliases used by existing code
val SaveButton = PremiumSuccess
val SaveButtonDisabled = PremiumSurfaceVariant
val SaveConfirm = PremiumSuccess
val ErrorRed = PremiumError

View file

@ -0,0 +1,69 @@
package com.rada.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
private val PremiumColorScheme = darkColorScheme(
primary = PremiumPurple,
onPrimary = Color.White,
primaryContainer = PremiumPurple.copy(alpha = 0.15f),
onPrimaryContainer = PremiumPurpleLight,
secondary = PremiumBlue,
onSecondary = Color.White,
secondaryContainer = PremiumBlue.copy(alpha = 0.15f),
onSecondaryContainer = PremiumBlue,
tertiary = PremiumCyan,
onTertiary = Color.White,
tertiaryContainer = PremiumCyan.copy(alpha = 0.15f),
onTertiaryContainer = PremiumCyan,
background = PremiumBlack,
onBackground = PremiumText,
surface = PremiumDark,
onSurface = PremiumText,
surfaceVariant = PremiumSurface,
onSurfaceVariant = PremiumTextSecondary,
error = PremiumError,
onError = Color.White,
errorContainer = PremiumError.copy(alpha = 0.15f),
onErrorContainer = PremiumError,
outline = PremiumBorder,
outlineVariant = PremiumBorder.copy(alpha = 0.5f),
inverseSurface = PremiumText,
inverseOnSurface = PremiumBlack,
inversePrimary = PremiumPurpleLight,
surfaceTint = PremiumPurple,
)
object RadAmpGradients {
val primary = Brush.linearGradient(
colors = listOf(GradientStart, GradientMid, GradientEnd)
)
val primaryHorizontal = Brush.horizontalGradient(
colors = listOf(GradientStart, GradientMid, GradientEnd)
)
val subtle = Brush.verticalGradient(
colors = listOf(PremiumSurface, PremiumBlack)
)
val cardGlow = Brush.radialGradient(
colors = listOf(PremiumPurple.copy(alpha = 0.1f), Color.Transparent)
)
}
@Composable
fun RadAmpTheme(
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = PremiumColorScheme,
content = content,
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RadAmp</string>
<string name="playback_channel_name">Playback</string>
<string name="playback_channel_description">Radio playback controls</string>
<string name="notification_play">Play</string>
<string name="notification_pause">Pause</string>
<string name="notification_stop">Stop</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RadAmp" parent="android:Theme.Material.NoActionBar" />
</resources>