23 Commits

Author SHA1 Message Date
nk
9ce9758361 feat(player): wire StationsScreen and FavoritesScreen to actual playback 2026-06-01 13:19:23 +03:00
nk
ada422053f feat(deeplink): add DeeplinkNavigator, DeeplinkBottomSheet and integrate into History and Player 2026-06-01 13:15:31 +03:00
nk
aa287f7588 feat(ui): integrate MiniPlayer into Scaffold with real player state 2026-06-01 13:12:51 +03:00
nk
9e3ce0f1e4 feat(ui): wire all screens into NavHost with BottomSheet player 2026-06-01 13:07:11 +03:00
nk
15e15c6664 feat(ui): add SettingsScreen and SettingsViewModel 2026-06-01 13:06:08 +03:00
nk
d3cec3bba2 feat(ui): add HistoryScreen and HistoryViewModel 2026-06-01 13:04:35 +03:00
nk
b2af81c070 feat(ui): add FavoritesScreen and FavoritesViewModel 2026-06-01 13:03:24 +03:00
nk
ee91837910 feat(ui): add PlayerBottomSheet and PlayerViewModel 2026-06-01 13:02:16 +03:00
nk
1c902b5607 feat(ui): add StationsScreen and StationsViewModel 2026-06-01 13:00:12 +03:00
nk
116ab95abd feat(ui): add shared components (StationCard, TrackListItem, SearchBar, FilterChips, MiniPlayer, EmptyState) 2026-06-01 12:58:25 +03:00
nk
28309c201e chore: add Lucide icons dependency and replace Material Icons in navigation 2026-06-01 12:54:36 +03:00
nk
c46a865742 feat(ui): add bottom navigation with 4 tabs 2026-06-01 12:23:27 +03:00
nk
cf79af5273 feat(service): add ExoPlayer controller and MediaSessionService 2026-06-01 12:21:22 +03:00
nk
9d68459df6 feat(di): add Hilt AppModule with Retrofit, Room and repository bindings 2026-06-01 12:19:33 +03:00
nk
e255b0458d feat(data): add repository implementations 2026-06-01 12:18:10 +03:00
nk
110fe0795e feat(data): add Room entities, DAOs and AppDatabase 2026-06-01 12:12:55 +03:00
nk
62674fcc3f feat(data): add Retrofit API, DTOs and mappers 2026-06-01 12:11:15 +03:00
nk
828cdf9a50 feat(domain): add core use cases 2026-06-01 12:08:49 +03:00
nk
aad1361043 feat(domain): add repository interfaces 2026-06-01 12:07:29 +03:00
nk
d345cd40b8 feat(domain): add Station, Track, PlayerState, DeeplinkService models 2026-06-01 12:05:13 +03:00
nk
7b1aa0c073 feat: add Application, MainActivity and Material3 theme 2026-06-01 12:02:35 +03:00
nk
8f26bf16d3 chore: add app module build config and manifest 2026-06-01 11:57:59 +03:00
nk
082e895f6a chore: bootstrap root Gradle with version catalog 2026-06-01 11:54:39 +03:00
69 changed files with 3857 additions and 0 deletions

108
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,108 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.radiola"
compileSdk = 34
defaultConfig {
applicationId = "com.radiola"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
testOptions {
unitTests {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp.logging)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
testImplementation(libs.room.testing)
implementation(libs.datastore.preferences)
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.coil.compose)
implementation(libs.lucide)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

7
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# Keep DataStore serializer
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
# Keep kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<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.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:name=".RadiolaApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Radiola"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Radiola"
android:configChanges="orientation|screenSize|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.PlayerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<receiver
android:name=".widget.PlayerWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/player_widget_info" />
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,105 @@
package com.radiola
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.radiola.ui.components.MiniPlayer
import com.radiola.ui.favorites.FavoritesScreen
import com.radiola.ui.history.HistoryScreen
import com.radiola.ui.navigation.BottomNavBar
import com.radiola.ui.navigation.NavDestinations
import com.radiola.ui.player.PlayerBottomSheet
import com.radiola.ui.player.PlayerViewModel
import com.radiola.ui.settings.SettingsScreen
import com.radiola.ui.stations.StationsScreen
import com.radiola.ui.theme.RadiolaTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RadiolaTheme {
val navController = rememberNavController()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showPlayer by remember { mutableStateOf(false) }
val playerViewModel: PlayerViewModel = hiltViewModel()
val isPlaying by playerViewModel.isPlaying.collectAsState()
val currentStation by playerViewModel.currentStation.collectAsState()
val currentTrack by playerViewModel.currentTrack.collectAsState()
Scaffold(
bottomBar = {
Column {
if (currentStation != null) {
MiniPlayer(
stationName = currentStation!!.name,
track = currentTrack,
isPlaying = isPlaying,
onClick = { showPlayer = true },
onPlayPause = { playerViewModel.togglePlayPause() }
)
}
BottomNavBar(navController)
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = NavDestinations.Stations.route,
modifier = Modifier.padding(paddingValues)
) {
composable(NavDestinations.Stations.route) {
StationsScreen(
onStationClick = { station ->
playerViewModel.play(station)
showPlayer = true
}
)
}
composable(NavDestinations.Favorites.route) {
FavoritesScreen(
onStationClick = { station ->
playerViewModel.play(station)
showPlayer = true
}
)
}
composable(NavDestinations.History.route) {
HistoryScreen()
}
composable(NavDestinations.Settings.route) {
SettingsScreen()
}
}
}
if (showPlayer) {
ModalBottomSheet(
onDismissRequest = { showPlayer = false },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background
) {
PlayerBottomSheet(
station = currentStation,
track = currentTrack,
isPlaying = isPlaying,
onPlayPause = { playerViewModel.togglePlayPause() }
)
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,17 @@
package com.radiola.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TrackHistoryDao
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TrackHistoryEntity
@Database(
entities = [StationEntity::class, TrackHistoryEntity::class],
version = 1
)
abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun trackHistoryDao(): TrackHistoryDao
}

View File

@@ -0,0 +1,34 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.radiola.data.local.entity.StationEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface StationDao {
@Query("SELECT * FROM stations ORDER BY sortOrder ASC")
fun getAll(): Flow<List<StationEntity>>
@Query("SELECT * FROM stations WHERE isFavorite = 1 ORDER BY sortOrder ASC")
fun getFavorites(): Flow<List<StationEntity>>
@Query("SELECT * FROM stations WHERE id = :id")
fun getById(id: Int): Flow<StationEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(stations: List<StationEntity>)
@Update
suspend fun update(station: StationEntity)
@Query("UPDATE stations SET isFavorite = :isFavorite WHERE id = :id")
suspend fun setFavorite(id: Int, isFavorite: Boolean)
@Query("SELECT isFavorite FROM stations WHERE id = :id")
fun isFavorite(id: Int): Flow<Boolean>
}

View File

@@ -0,0 +1,26 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.radiola.data.local.entity.TrackHistoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TrackHistoryDao {
@Query("SELECT * FROM track_history ORDER BY timestamp DESC LIMIT 200")
fun getAll(): Flow<List<TrackHistoryEntity>>
@Insert
suspend fun insert(track: TrackHistoryEntity)
@Query("DELETE FROM track_history WHERE id = :id")
suspend fun deleteById(id: Int)
@Query("DELETE FROM track_history WHERE id NOT IN (SELECT id FROM track_history ORDER BY timestamp DESC LIMIT 200)")
suspend fun cleanupOld()
@Query("SELECT * FROM track_history WHERE artist LIKE '%' || :query || '%' OR song LIKE '%' || :query || '%' ORDER BY timestamp DESC")
suspend fun search(query: String): List<TrackHistoryEntity>
}

View File

@@ -0,0 +1,17 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "stations")
data class StationEntity(
@PrimaryKey val id: Int,
val name: String,
val prefix: String,
val streamUrl: String,
val coverUrl: String,
val genre: String,
val tags: String,
val sortOrder: Int,
val isFavorite: Boolean = false
)

View File

@@ -0,0 +1,14 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "track_history")
data class TrackHistoryEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val artist: String,
val song: String,
val stationName: String,
val coverUrl: String?,
val timestamp: Long
)

View File

@@ -0,0 +1,32 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.NowPlayingItemDto
import com.radiola.data.remote.dto.StationDto
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
object ApiMapper {
fun StationDto.toDomain(): Station {
val cover = iconPng ?: iconSvg ?: ""
return Station(
id = id,
name = name,
prefix = prefix,
streamUrl = "https://air.radiorecord.ru:805/${prefix}_128",
coverUrl = cover,
genre = genre ?: "",
tags = emptyList(),
sortOrder = id
)
}
fun NowPlayingItemDto.toDomain(): Track {
return Track(
artist = artist,
song = song,
coverUrl = image600 ?: image100,
stationName = prefix
)
}
}

View File

@@ -0,0 +1,14 @@
package com.radiola.data.remote
import com.radiola.data.remote.dto.NowPlayingResponse
import com.radiola.data.remote.dto.StationsResponse
import retrofit2.http.GET
interface RecordApi {
@GET("api/stations/")
suspend fun getStations(): StationsResponse
@GET("api/stations/now/")
suspend fun getNowPlaying(): NowPlayingResponse
}

View File

@@ -0,0 +1,19 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class NowPlayingItemDto(
@SerialName("id") val id: Int,
@SerialName("prefix") val prefix: String,
@SerialName("artist") val artist: String,
@SerialName("song") val song: String,
@SerialName("image600") val image600: String? = null,
@SerialName("image100") val image100: String? = null
)
@Serializable
data class NowPlayingResponse(
@SerialName("result") val result: List<NowPlayingItemDto>
)

View File

@@ -0,0 +1,19 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class StationDto(
@SerialName("id") val id: Int,
@SerialName("title") val name: String,
@SerialName("prefix") val prefix: String,
@SerialName("genre") val genre: String? = null,
@SerialName("icon_png") val iconPng: String? = null,
@SerialName("icon_svg") val iconSvg: String? = null
)
@Serializable
data class StationsResponse(
@SerialName("result") val result: List<StationDto>
)

View File

@@ -0,0 +1,47 @@
package com.radiola.data.repository
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.StationEntity
import com.radiola.domain.model.Station
import com.radiola.domain.repository.FavoritesRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class FavoritesRepositoryImpl @Inject constructor(
private val db: AppDatabase
) : FavoritesRepository {
override fun getFavorites(): Flow<List<Station>> {
return db.stationDao().getFavorites().map { list ->
list.map { it.toDomain() }
}
}
override suspend fun addFavorite(station: Station) {
db.stationDao().setFavorite(station.id, true)
}
override suspend fun removeFavorite(stationId: Int) {
db.stationDao().setFavorite(stationId, false)
}
override fun isFavorite(stationId: Int): Flow<Boolean> {
return db.stationDao().isFavorite(stationId)
}
override suspend fun reorderFavorites(orderedIds: List<Int>) {
// TODO: implement drag-sort reordering
}
private fun StationEntity.toDomain(): Station = Station(
id = id,
name = name,
prefix = prefix,
streamUrl = streamUrl,
coverUrl = coverUrl,
genre = genre,
tags = tags.split(",").filter { it.isNotBlank() },
sortOrder = sortOrder
)
}

View File

@@ -0,0 +1,34 @@
package com.radiola.data.repository
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.ApiMapper.toDomain
import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class NowPlayingRepositoryImpl @Inject constructor(
private val api: RecordApi
) : NowPlayingRepository {
private val _nowPlaying = MutableStateFlow<Map<String, Track>>(emptyMap())
override fun getNowPlaying(stationPrefix: String): Flow<Track?> {
return _nowPlaying.map { it[stationPrefix] }
}
override fun getAllNowPlaying(): Flow<Map<String, Track>> = _nowPlaying
override suspend fun refreshNowPlaying(): Result<Unit> {
return try {
val response = api.getNowPlaying()
val map = response.result.associate { it.prefix to it.toDomain() }
_nowPlaying.value = map
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,51 @@
package com.radiola.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class SettingsRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context
) : SettingsRepository {
private val dataStore = context.dataStore
companion object {
private val LAST_STATION_ID = intPreferencesKey("last_station_id")
private val SLEEP_TIMER = intPreferencesKey("sleep_timer_minutes")
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
}
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
override suspend fun setLastStationId(id: Int) = dataStore.edit { it[LAST_STATION_ID] = id }
override fun getSleepTimerMinutes(): Flow<Int> = dataStore.data.map { it[SLEEP_TIMER] ?: 30 }
override suspend fun setSleepTimerMinutes(minutes: Int) = dataStore.edit { it[SLEEP_TIMER] = minutes }
override fun getEnabledDeeplinkServices(): Flow<Set<String>> = dataStore.data.map {
it[ENABLED_SERVICES] ?: setOf("yandex", "vk", "spotify", "apple", "youtube")
}
override suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>) =
dataStore.edit { it[ENABLED_SERVICES] = serviceIds }
override fun getEqualizerPreset(): Flow<String> = dataStore.data.map { it[EQUALIZER_PRESET] ?: "Flat" }
override suspend fun setEqualizerPreset(preset: String) = dataStore.edit { it[EQUALIZER_PRESET] = preset }
override fun isRecordingEnabled(): Flow<Boolean> = dataStore.data.map { it[RECORDING_ENABLED] ?: false }
override suspend fun setRecordingEnabled(enabled: Boolean) = dataStore.edit { it[RECORDING_ENABLED] = enabled }
}

View File

@@ -0,0 +1,62 @@
package com.radiola.data.repository
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.remote.RecordApi
import com.radiola.data.remote.ApiMapper.toDomain
import com.radiola.domain.model.Station
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class StationRepositoryImpl @Inject constructor(
private val api: RecordApi,
private val db: AppDatabase
) : StationRepository {
override fun getStations(): Flow<List<Station>> {
return db.stationDao().getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun refreshStations(): Result<Unit> {
return try {
val response = api.getStations()
val entities = response.result.mapIndexed { index, dto ->
val domain = dto.toDomain()
StationEntity(
id = domain.id,
name = domain.name,
prefix = domain.prefix,
streamUrl = domain.streamUrl,
coverUrl = domain.coverUrl,
genre = domain.genre,
tags = domain.tags.joinToString(","),
sortOrder = index,
isFavorite = false
)
}
db.stationDao().insertAll(entities)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override fun getStationById(id: Int): Flow<Station?> {
return db.stationDao().getById(id).map { it?.toDomain() }
}
private fun StationEntity.toDomain(): Station = Station(
id = id,
name = name,
prefix = prefix,
streamUrl = streamUrl,
coverUrl = coverUrl,
genre = genre,
tags = tags.split(",").filter { it.isNotBlank() },
sortOrder = sortOrder
)
}

View File

@@ -0,0 +1,47 @@
package com.radiola.data.repository
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.TrackHistoryEntity
import com.radiola.domain.model.Track
import com.radiola.domain.repository.TrackHistoryRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class TrackHistoryRepositoryImpl @Inject constructor(
private val db: AppDatabase
) : TrackHistoryRepository {
override fun getHistory(): Flow<List<Track>> {
return db.trackHistoryDao().getAll().map { list ->
list.map { it.toDomain() }
}
}
override suspend fun addTrack(track: Track) {
val entity = TrackHistoryEntity(
artist = track.artist,
song = track.song,
stationName = track.stationName,
coverUrl = track.coverUrl,
timestamp = System.currentTimeMillis()
)
db.trackHistoryDao().insert(entity)
db.trackHistoryDao().cleanupOld()
}
override suspend fun removeTrack(track: Track) {
// Requires lookup by composite key; simplified for now
}
override suspend fun searchHistory(query: String): List<Track> {
return db.trackHistoryDao().search(query).map { it.toDomain() }
}
private fun TrackHistoryEntity.toDomain(): Track = Track(
artist = artist,
song = song,
coverUrl = coverUrl,
stationName = stationName
)
}

View File

@@ -0,0 +1,16 @@
package com.radiola.deeplink
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Track
object DeeplinkNavigator {
fun openSearch(context: Context, track: Track, service: DeeplinkService) {
val url = service.buildSearchUrl(track.artist, track.song)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
}
}

View File

@@ -0,0 +1,86 @@
package com.radiola.di
import android.content.Context
import androidx.room.Room
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.radiola.data.local.AppDatabase
import com.radiola.data.remote.RecordApi
import com.radiola.data.repository.FavoritesRepositoryImpl
import com.radiola.data.repository.NowPlayingRepositoryImpl
import com.radiola.data.repository.SettingsRepositoryImpl
import com.radiola.data.repository.StationRepositoryImpl
import com.radiola.data.repository.TrackHistoryRepositoryImpl
import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.NowPlayingRepository
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.repository.TrackHistoryRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
.baseUrl("https://www.radiorecord.ru/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideRecordApi(retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
.build()
@Provides
@Singleton
fun provideStationRepository(impl: StationRepositoryImpl): StationRepository = impl
@Provides
@Singleton
fun provideNowPlayingRepository(impl: NowPlayingRepositoryImpl): NowPlayingRepository = impl
@Provides
@Singleton
fun provideFavoritesRepository(impl: FavoritesRepositoryImpl): FavoritesRepository = impl
@Provides
@Singleton
fun provideTrackHistoryRepository(impl: TrackHistoryRepositoryImpl): TrackHistoryRepository = impl
@Provides
@Singleton
fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl
}

View File

@@ -0,0 +1,21 @@
package com.radiola.domain.model
enum class DeeplinkService(
val serviceId: String,
val displayName: String,
val searchUrlTemplate: String
) {
YANDEX("yandex", "Яндекс Музыка", "https://music.yandex.ru/search?text=%s"),
VK("vk", "ВК Музыка", "https://vk.com/audio?q=%s"),
BOOM("boom", "BOOM", "https://boom.ru/search?query=%s"),
SPOTIFY("spotify", "Spotify", "https://open.spotify.com/search/%s"),
APPLE_MUSIC("apple", "Apple Music", "https://music.apple.com/search?term=%s"),
YOUTUBE_MUSIC("youtube", "YouTube Music", "https://music.youtube.com/search?q=%s"),
TIDAL("tidal", "Tidal", "https://listen.tidal.com/search?q=%s"),
DEEZER("deezer", "Deezer", "https://www.deezer.com/search/%s");
fun buildSearchUrl(artist: String, song: String): String {
val query = java.net.URLEncoder.encode("$artist $song", "UTF-8")
return searchUrlTemplate.format(query)
}
}

View File

@@ -0,0 +1,8 @@
package com.radiola.domain.model
sealed class PlayerState {
data object Idle : PlayerState()
data class Playing(val station: Station, val track: Track?) : PlayerState()
data class Buffering(val station: Station) : PlayerState()
data class Error(val message: String) : PlayerState()
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.model
data class Station(
val id: Int,
val name: String,
val prefix: String,
val streamUrl: String,
val coverUrl: String,
val genre: String,
val tags: List<String>,
val sortOrder: Int
)

View File

@@ -0,0 +1,8 @@
package com.radiola.domain.model
data class Track(
val artist: String,
val song: String,
val coverUrl: String?,
val stationName: String
)

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Station
import kotlinx.coroutines.flow.Flow
interface FavoritesRepository {
fun getFavorites(): Flow<List<Station>>
suspend fun addFavorite(station: Station)
suspend fun removeFavorite(stationId: Int)
fun isFavorite(stationId: Int): Flow<Boolean>
suspend fun reorderFavorites(orderedIds: List<Int>)
}

View File

@@ -0,0 +1,10 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow
interface NowPlayingRepository {
fun getNowPlaying(stationPrefix: String): Flow<Track?>
fun getAllNowPlaying(): Flow<Map<String, Track>>
suspend fun refreshNowPlaying(): Result<Unit>
}

View File

@@ -0,0 +1,17 @@
package com.radiola.domain.repository
import com.radiola.domain.model.DeeplinkService
import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
fun getLastStationId(): Flow<Int?>
suspend fun setLastStationId(id: Int)
fun getSleepTimerMinutes(): Flow<Int>
suspend fun setSleepTimerMinutes(minutes: Int)
fun getEnabledDeeplinkServices(): Flow<Set<String>>
suspend fun setEnabledDeeplinkServices(serviceIds: Set<String>)
fun getEqualizerPreset(): Flow<String>
suspend fun setEqualizerPreset(preset: String)
fun isRecordingEnabled(): Flow<Boolean>
suspend fun setRecordingEnabled(enabled: Boolean)
}

View File

@@ -0,0 +1,10 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Station
import kotlinx.coroutines.flow.Flow
interface StationRepository {
fun getStations(): Flow<List<Station>>
suspend fun refreshStations(): Result<Unit>
fun getStationById(id: Int): Flow<Station?>
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.repository
import com.radiola.domain.model.Track
import kotlinx.coroutines.flow.Flow
interface TrackHistoryRepository {
fun getHistory(): Flow<List<Track>>
suspend fun addTrack(track: Track)
suspend fun removeTrack(track: Track)
suspend fun searchHistory(query: String): List<Track>
}

View File

@@ -0,0 +1,14 @@
package com.radiola.domain.usecase
import com.radiola.domain.model.Track
import com.radiola.domain.repository.NowPlayingRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetNowPlayingUseCase @Inject constructor(
private val nowPlayingRepository: NowPlayingRepository
) {
operator fun invoke(stationPrefix: String): Flow<Track?> {
return nowPlayingRepository.getNowPlaying(stationPrefix)
}
}

View File

@@ -0,0 +1,12 @@
package com.radiola.domain.usecase
import com.radiola.domain.model.Station
import com.radiola.domain.repository.StationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetStationsUseCase @Inject constructor(
private val stationRepository: StationRepository
) {
operator fun invoke(): Flow<List<Station>> = stationRepository.getStations()
}

View File

@@ -0,0 +1,14 @@
package com.radiola.domain.usecase
import com.radiola.domain.model.Station
import com.radiola.domain.repository.SettingsRepository
import javax.inject.Inject
class PlayStationUseCase @Inject constructor(
private val settingsRepository: SettingsRepository
) {
suspend operator fun invoke(station: Station) {
settingsRepository.setLastStationId(station.id)
// Actual playback is delegated to PlayerService
}
}

View File

@@ -0,0 +1,11 @@
package com.radiola.domain.usecase
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Track
import javax.inject.Inject
class SearchTrackInServiceUseCase @Inject constructor() {
operator fun invoke(track: Track, service: DeeplinkService): String {
return service.buildSearchUrl(track.artist, track.song)
}
}

View File

@@ -0,0 +1,19 @@
package com.radiola.domain.usecase
import com.radiola.domain.model.Station
import com.radiola.domain.repository.FavoritesRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class ToggleFavoriteUseCase @Inject constructor(
private val favoritesRepository: FavoritesRepository
) {
suspend operator fun invoke(station: Station) {
val isFav = favoritesRepository.isFavorite(station.id).first()
if (isFav) {
favoritesRepository.removeFavorite(station.id)
} else {
favoritesRepository.addFavorite(station)
}
}
}

View File

@@ -0,0 +1,63 @@
package com.radiola.service
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PlayerController @Inject constructor(
@ApplicationContext context: Context
) {
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying
private val _currentStationPrefix = MutableStateFlow<String?>(null)
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true
)
.setHandleAudioBecomingNoisy(true)
.build()
.apply {
addListener(object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing
}
})
}
fun play(url: String, stationPrefix: String) {
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.play()
_currentStationPrefix.value = stationPrefix
}
fun pause() {
exoPlayer.pause()
}
fun stop() {
exoPlayer.stop()
_currentStationPrefix.value = null
}
fun release() {
exoPlayer.release()
}
}

View File

@@ -0,0 +1,51 @@
package com.radiola.service
import android.app.PendingIntent
import android.content.Intent
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.radiola.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
@UnstableApi
class PlayerService : MediaSessionService() {
@Inject
lateinit var playerController: PlayerController
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
mediaSession = MediaSession.Builder(this, playerController.exoPlayer)
.setSessionActivity(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
override fun onTaskRemoved(rootIntent: Intent?) {
if (!playerController.isPlaying.value) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession = null
super.onDestroy()
}
}

View File

@@ -0,0 +1,50 @@
package com.radiola.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Track
import com.radiola.ui.player.PlayerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeeplinkBottomSheet(
track: Track,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel()
) {
val context = androidx.compose.ui.platform.LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier
) {
Text(
text = "Найти трек",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
LazyColumn {
items(enabledServices) { service ->
ListItem(
headlineContent = { Text(service.displayName) },
modifier = Modifier.clickable {
DeeplinkNavigator.openSearch(context, track, service)
onDismiss()
}
)
}
}
}
}

View File

@@ -0,0 +1,27 @@
package com.radiola.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
message: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888)
)
}
}

View File

@@ -0,0 +1,39 @@
package com.radiola.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun FilterChips(
tags: List<String>,
selectedTag: String?,
onTagSelected: (String?) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
item {
FilterChip(
selected = selectedTag == null,
onClick = { onTagSelected(null) },
label = { Text("Все") }
)
}
items(tags) { tag ->
FilterChip(
selected = selectedTag == tag,
onClick = { onTagSelected(tag) },
label = { Text(tag) }
)
}
}
}

View File

@@ -0,0 +1,70 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.radiola.domain.model.Track
@Composable
fun MiniPlayer(
stationName: String,
track: Track?,
isPlaying: Boolean,
onClick: () -> Unit,
onPlayPause: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.background(Color(0xFF1E1E1E))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track?.coverUrl,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp))
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stationName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
)
Text(
text = track?.let { "${it.artist}${it.song}" } ?: "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White
)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
placeholder: String = "Поиск станции...",
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)),
placeholder = { Text(placeholder, color = Color(0xFF888888)) },
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = Color(0xFF888888)) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color(0xFF2A2A2A),
unfocusedContainerColor = Color(0xFF2A2A2A),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
}

View File

@@ -0,0 +1,64 @@
package com.radiola.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.radiola.domain.model.Station
@Composable
fun StationCard(
station: Station,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.aspectRatio(1f)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF667eea),
Color(0xFF764ba2)
)
)
)
) {
AsyncImage(
model = station.coverUrl,
contentDescription = station.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Text(
text = station.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(12.dp),
maxLines = 1
)
}
}
}

View File

@@ -0,0 +1,61 @@
package com.radiola.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.radiola.domain.model.Track
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun TrackListItem(
track: Track,
timestamp: Long? = null,
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track.coverUrl,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${track.artist}${track.song}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
)
Text(
text = track.stationName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
timestamp?.let {
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}
}

View File

@@ -0,0 +1,59 @@
package com.radiola.ui.favorites
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.Station
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.StationCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FavoritesScreen(
onStationClick: (Station) -> Unit,
modifier: Modifier = Modifier,
viewModel: FavoritesViewModel = hiltViewModel()
) {
val favorites by viewModel.favorites.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Избранное") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
if (favorites.isEmpty()) {
EmptyState(
message = "Нет избранных станций",
modifier = Modifier.fillMaxSize().padding(padding)
)
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(favorites, key = { it.id }) { station ->
StationCard(
station = station,
onClick = { onStationClick(station) }
)
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package com.radiola.ui.favorites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.repository.FavoritesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel @Inject constructor(
favoritesRepository: FavoritesRepository
) : ViewModel() {
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}

View File

@@ -0,0 +1,73 @@
package com.radiola.ui.history
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.Track
import com.radiola.ui.components.DeeplinkBottomSheet
import com.radiola.ui.components.EmptyState
import com.radiola.ui.components.SearchBar
import com.radiola.ui.components.TrackListItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
modifier: Modifier = Modifier,
viewModel: HistoryViewModel = hiltViewModel()
) {
val history by viewModel.history.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
var selectedTrack by remember { mutableStateOf<Track?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("История") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding)
) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
placeholder = "Поиск в истории...",
modifier = Modifier.padding(16.dp)
)
if (history.isEmpty()) {
EmptyState(message = "История пуста", modifier = Modifier.fillMaxSize())
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(history) { track ->
TrackListItem(
track = track,
onClick = { selectedTrack = track }
)
Divider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f))
}
}
}
}
}
if (selectedTrack != null) {
DeeplinkBottomSheet(
track = selectedTrack!!,
onDismiss = { selectedTrack = null }
)
}
}

View File

@@ -0,0 +1,39 @@
package com.radiola.ui.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Track
import com.radiola.domain.repository.TrackHistoryRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HistoryViewModel @Inject constructor(
private val trackHistoryRepository: TrackHistoryRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val history: StateFlow<List<Track>> = combine(
trackHistoryRepository.getHistory(),
_searchQuery
) { tracks, query ->
if (query.isBlank()) tracks else tracks.filter {
it.artist.contains(query, ignoreCase = true) ||
it.song.contains(query, ignoreCase = true)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
}
fun removeTrack(track: Track) {
viewModelScope.launch {
trackHistoryRepository.removeTrack(track)
}
}
}

View File

@@ -0,0 +1,32 @@
package com.radiola.ui.navigation
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomNavBar(navController: NavController) {
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
NavigationBar {
NavDestinations.items.forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.icon, contentDescription = destination.labelRes) },
label = { Text(destination.labelRes) },
selected = currentRoute == destination.route,
onClick = {
if (currentRoute != destination.route) {
navController.navigate(destination.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
}
)
}
}
}

View File

@@ -0,0 +1,23 @@
package com.radiola.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.History
import com.composables.icons.lucide.Home
import com.composables.icons.lucide.Settings
sealed class NavDestinations(
val route: String,
val labelRes: String,
val icon: ImageVector
) {
data object Stations : NavDestinations("stations", "Радио", Lucide.Home)
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
data object History : NavDestinations("history", "История", Lucide.History)
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
companion object {
val items = listOf(Stations, Favorites, History, Settings)
}
}

View File

@@ -0,0 +1,169 @@
package com.radiola.ui.player
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.deeplink.DeeplinkNavigator
import com.radiola.domain.model.Track
@Composable
fun PlayerBottomSheet(
station: Station?,
track: Track?,
isPlaying: Boolean,
onPlayPause: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel()
) {
val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState()
Column(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color(0xFF1a1a2e), Color(0xFF121212))
)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
station?.let {
Text(
text = it.name,
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF888888)
)
}
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(20.dp))
.background(
Brush.linearGradient(listOf(Color(0xFF667eea), Color(0xFF764ba2)))
)
) {
AsyncImage(
model = track?.coverUrl ?: station?.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = track?.song ?: "",
style = MaterialTheme.typography.headlineMedium,
maxLines = 1
)
Text(
text = track?.artist ?: "",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF888888),
maxLines = 1
)
Spacer(modifier = Modifier.height(20.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(enabledServices) { service ->
DeeplinkButton(
service = service,
onClick = {
track?.let { t ->
DeeplinkNavigator.openSearch(context, t, service)
}
}
)
}
}
Spacer(modifier = Modifier.height(30.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
ControlButton(size = 56.dp, onClick = { })
ControlButton(
size = 72.dp,
isPlay = true,
isPlaying = isPlaying,
onClick = onPlayPause
)
ControlButton(size = 56.dp, onClick = { })
}
}
}
@Composable
private fun DeeplinkButton(
service: DeeplinkService,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF2A2A2A))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = service.displayName.take(2),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF888888)
)
}
}
@Composable
private fun ControlButton(
size: androidx.compose.ui.unit.Dp,
isPlay: Boolean = false,
isPlaying: Boolean = false,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(if (isPlay) Color.White else Color(0xFF2A2A2A))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (isPlay) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.Black,
modifier = Modifier.size(size * 0.4f)
)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.radiola.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.model.Station
import com.radiola.domain.model.Track
import com.radiola.domain.repository.SettingsRepository
import com.radiola.domain.repository.StationRepository
import com.radiola.domain.usecase.GetNowPlayingUseCase
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
import com.radiola.service.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val playerController: PlayerController,
private val stationRepository: StationRepository,
private val getNowPlayingUseCase: GetNowPlayingUseCase,
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
private val settingsRepository: SettingsRepository
) : ViewModel() {
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
private val _currentTrack = MutableStateFlow<Track?>(null)
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.getEnabledDeeplinkServices().collect { ids ->
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
}
}
viewModelScope.launch {
currentStationPrefix.collect { prefix ->
prefix?.let { p ->
// Find station by prefix from repository
// Note: repository only has getStationById; we use a workaround
// In real implementation, add getStationByPrefix to repository
}
}
}
}
fun play(station: Station) {
_currentStation.value = station
playerController.play(station.streamUrl, station.prefix)
viewModelScope.launch {
getNowPlayingUseCase(station.prefix).collect { track ->
_currentTrack.value = track
}
}
}
fun pause() {
playerController.pause()
}
fun resume() {
playerController.exoPlayer.play()
}
fun togglePlayPause() {
if (isPlaying.value) pause() else resume()
}
fun getDeeplinkUrl(track: Track, service: DeeplinkService): String {
return searchTrackInServiceUseCase(track, service)
}
}

View File

@@ -0,0 +1,104 @@
package com.radiola.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.domain.model.DeeplinkService
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
val enabledServices by viewModel.enabledServices.collectAsState()
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
Slider(
value = sleepTimer.toFloat(),
onValueChange = { viewModel.setSleepTimer(it.toInt()) },
valueRange = 5f..120f,
steps = 22
)
Text("$sleepTimer мин", style = MaterialTheme.typography.bodyMedium)
}
item {
Text("Эквалайзер", style = MaterialTheme.typography.titleMedium)
SingleChoiceSegmentedButtonRow {
presets.forEach { preset ->
SegmentedButton(
selected = equalizerPreset == preset,
onClick = { viewModel.setEqualizerPreset(preset) },
shape = MaterialTheme.shapes.small
) {
Text(preset)
}
}
}
}
item {
Text("Музыкальные сервисы", style = MaterialTheme.typography.titleMedium)
Column {
DeeplinkService.entries.forEach { service ->
val checked = service.serviceId in enabledServices
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.toggleService(service.serviceId, !checked) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(service.displayName)
Switch(
checked = checked,
onCheckedChange = { viewModel.toggleService(service.serviceId, it) }
)
}
}
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setRecordingEnabled(!isRecordingEnabled) }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Запись эфира")
Switch(
checked = isRecordingEnabled,
onCheckedChange = { viewModel.setRecordingEnabled(it) }
)
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
package com.radiola.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.DeeplinkService
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
val sleepTimerMinutes: StateFlow<Int> = settingsRepository.getSleepTimerMinutes()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 30)
val enabledServices: StateFlow<Set<String>> = settingsRepository.getEnabledDeeplinkServices()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun setSleepTimer(minutes: Int) {
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
}
fun toggleService(serviceId: String, enabled: Boolean) {
viewModelScope.launch {
val current = enabledServices.value.toMutableSet()
if (enabled) current.add(serviceId) else current.remove(serviceId)
settingsRepository.setEnabledDeeplinkServices(current)
}
}
fun setEqualizerPreset(preset: String) {
viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) }
}
fun setRecordingEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
}
}

View File

@@ -0,0 +1,77 @@
package com.radiola.ui.stations
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.radiola.ui.components.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StationsScreen(
onStationClick: (Station) -> Unit,
modifier: Modifier = Modifier,
viewModel: StationsViewModel = hiltViewModel()
) {
val stations by viewModel.stations.collectAsState()
val tags by viewModel.tags.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val selectedTag by viewModel.selectedTag.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Радио") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding)
) {
SearchBar(
query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier.padding(16.dp)
)
FilterChips(
tags = tags,
selectedTag = selectedTag,
onTagSelected = viewModel::onTagSelected,
modifier = Modifier.padding(vertical = 8.dp)
)
when {
isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
CircularProgressIndicator()
}
error != null -> EmptyState(message = error!!)
stations.isEmpty() -> EmptyState(message = "Станции не найдены")
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
onClick = { onStationClick(station) }
)
}
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package com.radiola.ui.stations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Station
import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.PlayStationUseCase
import com.radiola.domain.usecase.ToggleFavoriteUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StationsViewModel @Inject constructor(
private val getStationsUseCase: GetStationsUseCase,
private val playStationUseCase: PlayStationUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _selectedTag = MutableStateFlow<String?>(null)
val selectedTag: StateFlow<String?> = _selectedTag.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
val stations: StateFlow<List<Station>> = combine(
getStationsUseCase(),
_searchQuery,
_selectedTag
) { allStations, query, tag ->
allStations
.filter { station ->
tag == null || station.tags.contains(tag) || station.genre.equals(tag, ignoreCase = true)
}
.filter { station ->
query.isBlank() ||
station.name.contains(query, ignoreCase = true) ||
station.genre.contains(query, ignoreCase = true)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val tags: StateFlow<List<String>> = getStationsUseCase()
.map { stations -> stations.flatMap { it.tags }.distinct().sorted() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
}
fun onTagSelected(tag: String?) {
_selectedTag.value = tag
}
fun playStation(station: Station) {
viewModelScope.launch {
playStationUseCase(station)
}
}
fun toggleFavorite(station: Station) {
viewModelScope.launch {
toggleFavoriteUseCase(station)
}
}
}

View File

@@ -0,0 +1,13 @@
package com.radiola.ui.theme
import androidx.compose.ui.graphics.Color
val Primary = Color(0xFF6200EE)
val PrimaryDark = Color(0xFF3700B3)
val Secondary = Color(0xFF03DAC6)
val Background = Color(0xFF121212)
val Surface = Color(0xFF1E1E1E)
val OnPrimary = Color.White
val OnSecondary = Color.Black
val OnBackground = Color.White
val OnSurface = Color.White

View File

@@ -0,0 +1,38 @@
package com.radiola.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = Primary,
secondary = Secondary,
background = Background,
surface = Surface,
onPrimary = OnPrimary,
onSecondary = OnSecondary,
onBackground = OnBackground,
onSurface = OnSurface
)
private val LightColorScheme = lightColorScheme(
primary = Primary,
secondary = Secondary,
onPrimary = OnPrimary,
onSecondary = OnSecondary
)
@Composable
fun RadiolaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,35 @@
package com.radiola.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
)

View File

@@ -0,0 +1,8 @@
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,10 @@
<resources>
<string name="app_name">radiOLA</string>
<string name="tab_radio">Радио</string>
<string name="tab_favorites">Избранное</string>
<string name="tab_history">История</string>
<string name="tab_settings">Настройки</string>
<string name="offline_message">Offline mode</string>
<string name="player_play">Play</string>
<string name="player_pause">Pause</string>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<style name="Theme.Radiola" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="."/>
<exclude domain="database" path="."/>
</full-backup-content>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="."/>
<exclude domain="database" path="."/>
</cloud-backup>
</data-extraction-rules>

7
build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

File diff suppressed because it is too large Load Diff

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

77
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,77 @@
[versions]
agp = "8.5.0"
kotlin = "2.0.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.2"
activityCompose = "1.9.0"
composeBom = "2024.06.00"
hilt = "2.51.1"
ksp = "2.0.0-1.0.22"
retrofit = "2.11.0"
serialization = "1.6.3"
room = "2.6.1"
datastore = "1.1.1"
media3 = "1.3.1"
coil = "2.6.0"
navigation = "2.7.7"
lucide = "1.0.0"
mockk = "1.13.11"
turbine = "1.1.0"
coroutinesTest = "1.8.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
lucide = { group = "com.composables", name = "icons-lucide", version.ref = "lucide" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version = "4.12.0" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

16
settings.gradle.kts Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "radiOLA"
include(":app")