Compare commits
23 Commits
main
...
9ce9758361
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce9758361 | ||
|
|
ada422053f | ||
|
|
aa287f7588 | ||
|
|
9e3ce0f1e4 | ||
|
|
15e15c6664 | ||
|
|
d3cec3bba2 | ||
|
|
b2af81c070 | ||
|
|
ee91837910 | ||
|
|
1c902b5607 | ||
|
|
116ab95abd | ||
|
|
28309c201e | ||
|
|
c46a865742 | ||
|
|
cf79af5273 | ||
|
|
9d68459df6 | ||
|
|
e255b0458d | ||
|
|
110fe0795e | ||
|
|
62674fcc3f | ||
|
|
828cdf9a50 | ||
|
|
aad1361043 | ||
|
|
d345cd40b8 | ||
|
|
7b1aa0c073 | ||
|
|
8f26bf16d3 | ||
|
|
082e895f6a |
108
app/build.gradle.kts
Normal file
108
app/build.gradle.kts
Normal 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
7
app/proguard-rules.pro
vendored
Normal 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
|
||||
56
app/src/main/AndroidManifest.xml
Normal file
56
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
105
app/src/main/java/com/radiola/MainActivity.kt
Normal file
105
app/src/main/java/com/radiola/MainActivity.kt
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/java/com/radiola/RadiolaApplication.kt
Normal file
7
app/src/main/java/com/radiola/RadiolaApplication.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.radiola
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class RadiolaApplication : Application()
|
||||
17
app/src/main/java/com/radiola/data/local/AppDatabase.kt
Normal file
17
app/src/main/java/com/radiola/data/local/AppDatabase.kt
Normal 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
|
||||
}
|
||||
34
app/src/main/java/com/radiola/data/local/dao/StationDao.kt
Normal file
34
app/src/main/java/com/radiola/data/local/dao/StationDao.kt
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
32
app/src/main/java/com/radiola/data/remote/ApiMapper.kt
Normal file
32
app/src/main/java/com/radiola/data/remote/ApiMapper.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/com/radiola/data/remote/RecordApi.kt
Normal file
14
app/src/main/java/com/radiola/data/remote/RecordApi.kt
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
19
app/src/main/java/com/radiola/data/remote/dto/StationDto.kt
Normal file
19
app/src/main/java/com/radiola/data/remote/dto/StationDto.kt
Normal 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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
16
app/src/main/java/com/radiola/deeplink/DeeplinkNavigator.kt
Normal file
16
app/src/main/java/com/radiola/deeplink/DeeplinkNavigator.kt
Normal 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, "Открыть в..."))
|
||||
}
|
||||
}
|
||||
86
app/src/main/java/com/radiola/di/AppModule.kt
Normal file
86
app/src/main/java/com/radiola/di/AppModule.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
12
app/src/main/java/com/radiola/domain/model/Station.kt
Normal file
12
app/src/main/java/com/radiola/domain/model/Station.kt
Normal 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
|
||||
)
|
||||
8
app/src/main/java/com/radiola/domain/model/Track.kt
Normal file
8
app/src/main/java/com/radiola/domain/model/Track.kt
Normal 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
|
||||
)
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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?>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/src/main/java/com/radiola/service/PlayerController.kt
Normal file
63
app/src/main/java/com/radiola/service/PlayerController.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
51
app/src/main/java/com/radiola/service/PlayerService.kt
Normal file
51
app/src/main/java/com/radiola/service/PlayerService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/radiola/ui/components/EmptyState.kt
Normal file
27
app/src/main/java/com/radiola/ui/components/EmptyState.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/radiola/ui/components/FilterChips.kt
Normal file
39
app/src/main/java/com/radiola/ui/components/FilterChips.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/src/main/java/com/radiola/ui/components/MiniPlayer.kt
Normal file
70
app/src/main/java/com/radiola/ui/components/MiniPlayer.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/src/main/java/com/radiola/ui/components/SearchBar.kt
Normal file
43
app/src/main/java/com/radiola/ui/components/SearchBar.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
64
app/src/main/java/com/radiola/ui/components/StationCard.kt
Normal file
64
app/src/main/java/com/radiola/ui/components/StationCard.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/src/main/java/com/radiola/ui/components/TrackListItem.kt
Normal file
61
app/src/main/java/com/radiola/ui/components/TrackListItem.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
73
app/src/main/java/com/radiola/ui/history/HistoryScreen.kt
Normal file
73
app/src/main/java/com/radiola/ui/history/HistoryScreen.kt
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt
Normal file
39
app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt
Normal file
32
app/src/main/java/com/radiola/ui/navigation/BottomNavBar.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
Normal file
169
app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
Normal file
81
app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
104
app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
Normal file
104
app/src/main/java/com/radiola/ui/settings/SettingsScreen.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/radiola/ui/stations/StationsScreen.kt
Normal file
77
app/src/main/java/com/radiola/ui/stations/StationsScreen.kt
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/radiola/ui/theme/Color.kt
Normal file
13
app/src/main/java/com/radiola/ui/theme/Color.kt
Normal 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
|
||||
38
app/src/main/java/com/radiola/ui/theme/Theme.kt
Normal file
38
app/src/main/java/com/radiola/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
35
app/src/main/java/com/radiola/ui/theme/Type.kt
Normal file
35
app/src/main/java/com/radiola/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
8
app/src/main/res/values/colors.xml
Normal file
8
app/src/main/res/values/colors.xml
Normal 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>
|
||||
10
app/src/main/res/values/strings.xml
Normal file
10
app/src/main/res/values/strings.xml
Normal 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>
|
||||
3
app/src/main/res/values/themes.xml
Normal file
3
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.Radiola" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
5
app/src/main/res/xml/backup_rules.xml
Normal file
5
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
7
app/src/main/res/xml/data_extraction_rules.xml
Normal file
7
app/src/main/res/xml/data_extraction_rules.xml
Normal 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
7
build.gradle.kts
Normal 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
|
||||
}
|
||||
1372
docs/superpowers/plans/2026-06-01-radiola-ui-screens.md
Normal file
1372
docs/superpowers/plans/2026-06-01-radiola-ui-screens.md
Normal file
File diff suppressed because it is too large
Load Diff
4
gradle.properties
Normal file
4
gradle.properties
Normal 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
77
gradle/libs.versions.toml
Normal 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
16
settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user