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