feat: auth screen with auto-redirect, sync favorites/history with backend
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
android:roundIcon="@drawable/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Radiola"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
@@ -41,6 +42,21 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.RecordingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.PlayerWidgetProvider"
|
||||
android:exported="false">
|
||||
|
||||
11452
app/src/main/assets/stations.json
Normal file
11452
app/src/main/assets/stations.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
package com.radiola
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -12,22 +13,40 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import com.radiola.ui.auth.AuthScreen
|
||||
import com.radiola.ui.components.MiniPlayer
|
||||
import com.radiola.ui.favorites.FavoritesScreen
|
||||
import com.radiola.ui.favorites.FavoritesViewModel
|
||||
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.recordings.RecordingsScreen
|
||||
import com.radiola.ui.settings.SettingsScreen
|
||||
import com.radiola.ui.stations.StationsScreen
|
||||
import com.radiola.ui.stations.StationsViewModel
|
||||
import com.radiola.service.PlayerService
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var tokenDataStore: TokenDataStore
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
lifecycleScope.launch {
|
||||
tokenDataStore.preload()
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
RadiolaTheme {
|
||||
@@ -35,9 +54,20 @@ class MainActivity : ComponentActivity() {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var showPlayer by remember { mutableStateOf(false) }
|
||||
val playerViewModel: PlayerViewModel = hiltViewModel()
|
||||
val stationsViewModel: StationsViewModel = hiltViewModel()
|
||||
val favoritesViewModel: FavoritesViewModel = hiltViewModel()
|
||||
val isPlaying by playerViewModel.isPlaying.collectAsState()
|
||||
val currentStation by playerViewModel.currentStation.collectAsState()
|
||||
val currentTrack by playerViewModel.currentTrack.collectAsState()
|
||||
val favoriteIds by favoritesViewModel.favoriteIds.collectAsState()
|
||||
val stations by stationsViewModel.stations.collectAsState()
|
||||
val favorites by favoritesViewModel.favorites.collectAsState()
|
||||
val isRecording by playerViewModel.isRecording.collectAsState()
|
||||
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
|
||||
|
||||
val startDestination = remember(isLoggedIn) {
|
||||
if (isLoggedIn) NavDestinations.Stations.route else NavDestinations.Auth.route
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
@@ -51,19 +81,21 @@ class MainActivity : ComponentActivity() {
|
||||
onPlayPause = { playerViewModel.togglePlayPause() }
|
||||
)
|
||||
}
|
||||
BottomNavBar(navController)
|
||||
if (isLoggedIn) {
|
||||
BottomNavBar(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = NavDestinations.Stations.route,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
composable(NavDestinations.Stations.route) {
|
||||
StationsScreen(
|
||||
onStationClick = { station ->
|
||||
playerViewModel.play(station)
|
||||
playerViewModel.play(station, stations)
|
||||
showPlayer = true
|
||||
}
|
||||
)
|
||||
@@ -71,7 +103,7 @@ class MainActivity : ComponentActivity() {
|
||||
composable(NavDestinations.Favorites.route) {
|
||||
FavoritesScreen(
|
||||
onStationClick = { station ->
|
||||
playerViewModel.play(station)
|
||||
playerViewModel.play(station, favorites)
|
||||
showPlayer = true
|
||||
}
|
||||
)
|
||||
@@ -79,8 +111,29 @@ class MainActivity : ComponentActivity() {
|
||||
composable(NavDestinations.History.route) {
|
||||
HistoryScreen()
|
||||
}
|
||||
composable(NavDestinations.Recordings.route) {
|
||||
RecordingsScreen()
|
||||
}
|
||||
composable(NavDestinations.Settings.route) {
|
||||
SettingsScreen()
|
||||
SettingsScreen(
|
||||
onNavigateToAuth = {
|
||||
navController.navigate(NavDestinations.Auth.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Auth.route) {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
navController.navigate(NavDestinations.Stations.route) {
|
||||
popUpTo(NavDestinations.Auth.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onSkip = {
|
||||
navController.navigate(NavDestinations.Stations.route) {
|
||||
popUpTo(NavDestinations.Auth.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +148,13 @@ class MainActivity : ComponentActivity() {
|
||||
station = currentStation,
|
||||
track = currentTrack,
|
||||
isPlaying = isPlaying,
|
||||
onPlayPause = { playerViewModel.togglePlayPause() }
|
||||
onPlayPause = { playerViewModel.togglePlayPause() },
|
||||
onNext = { playerViewModel.playNext() },
|
||||
onPrevious = { playerViewModel.playPrevious() },
|
||||
isFavorite = currentStation?.let { favoriteIds.contains(it.id) } ?: false,
|
||||
onToggleFavorite = { currentStation?.let { favoritesViewModel.toggleFavorite(it) } },
|
||||
isRecording = isRecording,
|
||||
onToggleRecording = { playerViewModel.toggleRecording() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,55 @@ package com.radiola.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.radiola.data.local.dao.RecordingDao
|
||||
import com.radiola.data.local.dao.StationDao
|
||||
import com.radiola.data.local.dao.TagDao
|
||||
import com.radiola.data.local.dao.TrackHistoryDao
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
import com.radiola.data.local.entity.TrackHistoryEntity
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY NOT NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE stations ADD COLUMN source TEXT NOT NULL DEFAULT 'record'")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
stationName TEXT NOT NULL,
|
||||
stationId INTEGER NOT NULL,
|
||||
filePath TEXT NOT NULL,
|
||||
startTime INTEGER NOT NULL,
|
||||
endTime INTEGER,
|
||||
trackName TEXT,
|
||||
duration INTEGER
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class],
|
||||
version = 1
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||
version = 4
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun stationDao(): StationDao
|
||||
abstract fun trackHistoryDao(): TrackHistoryDao
|
||||
abstract fun tagDao(): TagDao
|
||||
abstract fun recordingDao(): RecordingDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.radiola.data.local
|
||||
|
||||
import android.content.Context
|
||||
import com.radiola.data.local.dto.LocalGroupDto
|
||||
import com.radiola.data.local.dto.LocalStationsResponse
|
||||
import com.radiola.domain.model.Station
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedReader
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LocalStationDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val json: Json
|
||||
) {
|
||||
|
||||
private var cachedResponse: LocalStationsResponse? = null
|
||||
|
||||
fun loadStations(): List<Station> {
|
||||
android.util.Log.d("LocalStationDS", "loadStations() called")
|
||||
val response = getResponse()
|
||||
android.util.Log.d("LocalStationDS", "Parsed: ${response.stations.size} stations, ${response.groups.size} groups")
|
||||
val groupMap = response.groups.associateBy { it.id }
|
||||
|
||||
return response.stations
|
||||
.filter { it.enabled && !it.notWorked && it.stream != null }
|
||||
.map { dto ->
|
||||
val group = groupMap[dto.groupId]
|
||||
val prefix = generatePrefix(dto.name)
|
||||
Station(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
prefix = prefix,
|
||||
streamUrl = dto.stream!!,
|
||||
coverUrl = group?.let { generateCoverUrl(it.name, dto.name) } ?: "",
|
||||
genre = group?.name ?: "",
|
||||
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
|
||||
sortOrder = dto.id,
|
||||
source = "local"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGroups(): List<LocalGroupDto> {
|
||||
return getResponse().groups.filter { it.name.isNotBlank() }
|
||||
}
|
||||
|
||||
fun getGroupNames(): List<String> {
|
||||
return loadGroups().map { it.name }
|
||||
}
|
||||
|
||||
private fun getResponse(): LocalStationsResponse {
|
||||
android.util.Log.d("LocalStationDS", "getResponse() called")
|
||||
cachedResponse?.let { return it }
|
||||
val text = context.assets.open("stations.json").bufferedReader().use(BufferedReader::readText)
|
||||
val parsed = json.decodeFromString(LocalStationsResponse.serializer(), text)
|
||||
cachedResponse = parsed
|
||||
return parsed
|
||||
}
|
||||
|
||||
private fun generatePrefix(name: String): String {
|
||||
return name.lowercase()
|
||||
.replace(Regex("[^a-z0-9а-яё]+"), "_")
|
||||
.trim('_')
|
||||
.take(30)
|
||||
}
|
||||
|
||||
private fun generateCoverUrl(groupName: String, stationName: String): String {
|
||||
// Placeholder: return empty for now; Record API will override with real covers when available
|
||||
return ""
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/radiola/data/local/TokenDataStore.kt
Normal file
59
app/src/main/java/com/radiola/data/local/TokenDataStore.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.radiola.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")
|
||||
|
||||
@Singleton
|
||||
class TokenDataStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
@Volatile
|
||||
var currentToken: String? = null
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private val TOKEN_KEY = stringPreferencesKey("jwt_token")
|
||||
private val USER_ID_KEY = stringPreferencesKey("user_id")
|
||||
private val USER_EMAIL_KEY = stringPreferencesKey("user_email")
|
||||
private val USER_NAME_KEY = stringPreferencesKey("user_name")
|
||||
}
|
||||
|
||||
val token: Flow<String?> = dataStore.data.map { it[TOKEN_KEY] }
|
||||
val userId: Flow<String?> = dataStore.data.map { it[USER_ID_KEY] }
|
||||
val userEmail: Flow<String?> = dataStore.data.map { it[USER_EMAIL_KEY] }
|
||||
val userName: Flow<String?> = dataStore.data.map { it[USER_NAME_KEY] }
|
||||
|
||||
val isLoggedIn: Flow<Boolean> = token.map { !it.isNullOrBlank() }
|
||||
|
||||
suspend fun saveToken(token: String, userId: String, email: String, name: String?) {
|
||||
currentToken = token
|
||||
dataStore.edit { prefs ->
|
||||
prefs[TOKEN_KEY] = token
|
||||
prefs[USER_ID_KEY] = userId
|
||||
prefs[USER_EMAIL_KEY] = email
|
||||
name?.let { prefs[USER_NAME_KEY] = it }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
currentToken = null
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
suspend fun preload() {
|
||||
token.collect { currentToken = it }
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt
Normal file
26
app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RecordingDao {
|
||||
|
||||
@Query("SELECT * FROM recordings ORDER BY startTime DESC")
|
||||
fun getAll(): Flow<List<RecordingEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(recording: RecordingEntity)
|
||||
|
||||
@Query("DELETE FROM recordings WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
|
||||
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
|
||||
|
||||
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||
suspend fun getById(id: Long): RecordingEntity?
|
||||
}
|
||||
@@ -31,4 +31,7 @@ interface StationDao {
|
||||
|
||||
@Query("SELECT isFavorite FROM stations WHERE id = :id")
|
||||
fun isFavorite(id: Int): Flow<Boolean>
|
||||
|
||||
@Query("SELECT id FROM stations WHERE isFavorite = 1")
|
||||
fun getFavoriteIds(): Flow<List<Int>>
|
||||
}
|
||||
|
||||
20
app/src/main/java/com/radiola/data/local/dao/TagDao.kt
Normal file
20
app/src/main/java/com/radiola/data/local/dao/TagDao.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TagDao {
|
||||
@Query("SELECT * FROM tags ORDER BY name")
|
||||
fun getAll(): Flow<List<TagEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(tags: List<TagEntity>)
|
||||
|
||||
@Query("DELETE FROM tags")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.radiola.data.local.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LocalStationDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("groupId") val groupId: Int,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("bitrate") val bitrate: String? = null,
|
||||
@SerialName("site") val site: String? = null,
|
||||
@SerialName("stream") val stream: String? = null,
|
||||
@SerialName("type") val type: String? = null,
|
||||
@SerialName("iconText") val iconText: String? = null,
|
||||
@SerialName("textColor") val textColor: String? = null,
|
||||
@SerialName("bgColor") val bgColor: String? = null,
|
||||
@SerialName("enabled") val enabled: Boolean = true,
|
||||
@SerialName("notWorked") val notWorked: Boolean = false,
|
||||
@SerialName("isNew") val isNew: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalGroupDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("textColor") val textColor: String? = null,
|
||||
@SerialName("bgColor") val bgColor: String? = null,
|
||||
@SerialName("info") val info: String? = null,
|
||||
@SerialName("stations") val stations: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalStationsResponse(
|
||||
@SerialName("stations") val stations: List<LocalStationDto> = emptyList(),
|
||||
@SerialName("groups") val groups: List<LocalGroupDto> = emptyList(),
|
||||
@SerialName("config") val config: LocalConfigDto? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalConfigDto(
|
||||
@SerialName("stationsBeforeGroups") val stationsBeforeGroups: Boolean = false,
|
||||
@SerialName("version") val version: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "recordings")
|
||||
data class RecordingEntity(
|
||||
@PrimaryKey val id: Long,
|
||||
val stationName: String,
|
||||
val stationId: Int,
|
||||
val filePath: String,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val trackName: String?,
|
||||
val duration: Long?
|
||||
)
|
||||
@@ -13,5 +13,6 @@ data class StationEntity(
|
||||
val genre: String,
|
||||
val tags: String,
|
||||
val sortOrder: Int,
|
||||
val source: String = "record",
|
||||
val isFavorite: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
data class TagEntity(
|
||||
@PrimaryKey val name: String
|
||||
)
|
||||
@@ -8,25 +8,26 @@ import com.radiola.domain.model.Track
|
||||
object ApiMapper {
|
||||
|
||||
fun StationDto.toDomain(): Station {
|
||||
val cover = iconPng ?: iconSvg ?: ""
|
||||
val cover = iconFillColored ?: bgImageMobile ?: bgImage ?: ""
|
||||
val stream = stream128 ?: stream320 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
|
||||
return Station(
|
||||
id = id,
|
||||
name = name,
|
||||
prefix = prefix,
|
||||
streamUrl = "https://air.radiorecord.ru:805/${prefix}_128",
|
||||
streamUrl = stream,
|
||||
coverUrl = cover,
|
||||
genre = genre ?: "",
|
||||
tags = emptyList(),
|
||||
sortOrder = id
|
||||
genre = tooltip ?: "",
|
||||
tags = tags.map { it.name },
|
||||
sortOrder = sort
|
||||
)
|
||||
}
|
||||
|
||||
fun NowPlayingItemDto.toDomain(): Track {
|
||||
return Track(
|
||||
artist = artist,
|
||||
song = song,
|
||||
coverUrl = image600 ?: image100,
|
||||
stationName = prefix
|
||||
artist = track.artist,
|
||||
song = track.song,
|
||||
coverUrl = track.image600 ?: track.image100,
|
||||
stationName = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
25
app/src/main/java/com/radiola/data/remote/AuthInterceptor.kt
Normal file
25
app/src/main/java/com/radiola/data/remote/AuthInterceptor.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val token = tokenDataStore.currentToken
|
||||
|
||||
return if (token != null) {
|
||||
chain.proceed(
|
||||
request.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/radiola/data/remote/RadiolaApi.kt
Normal file
48
app/src/main/java/com/radiola/data/remote/RadiolaApi.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.AuthResponseDto
|
||||
import com.radiola.data.remote.dto.BackendStationDto
|
||||
import com.radiola.data.remote.dto.HistoryResponseDto
|
||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||
import com.radiola.data.remote.dto.UserSettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface RadiolaApi {
|
||||
|
||||
@POST("auth/magic-link")
|
||||
suspend fun requestMagicLink(@Body dto: MagicLinkRequestDto): JsonObject
|
||||
|
||||
@POST("auth/verify")
|
||||
suspend fun verifyMagicLink(@Body dto: MagicLinkVerifyDto): AuthResponseDto
|
||||
|
||||
@GET("users/me")
|
||||
suspend fun getMe(): JsonObject
|
||||
|
||||
@GET("users/me/settings")
|
||||
suspend fun getSettings(): UserSettingsDto
|
||||
|
||||
@PATCH("users/me/settings")
|
||||
suspend fun updateSettings(@Body dto: UserSettingsDto): UserSettingsDto
|
||||
|
||||
@GET("users/me/favorites")
|
||||
suspend fun getFavorites(): List<BackendStationDto>
|
||||
|
||||
@POST("users/me/favorites/{stationId}")
|
||||
suspend fun addFavorite(@Path("stationId") stationId: String): JsonObject
|
||||
|
||||
@DELETE("users/me/favorites/{stationId}")
|
||||
suspend fun removeFavorite(@Path("stationId") stationId: String): JsonObject
|
||||
|
||||
@GET("users/me/history")
|
||||
suspend fun getHistory(): HistoryResponseDto
|
||||
|
||||
@POST("users/me/history/{stationId}")
|
||||
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AuthResponseDto(
|
||||
val accessToken: String,
|
||||
val user: UserDto
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BackendStationDto(
|
||||
val id: String,
|
||||
val stationId: Int,
|
||||
val name: String,
|
||||
val prefix: String,
|
||||
val streamUrl: String,
|
||||
val coverUrl: String? = null,
|
||||
val genre: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val sortOrder: Int = 0,
|
||||
val source: String = "local",
|
||||
val isOnline: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FavoritesResponseDto(
|
||||
val favorites: List<BackendStationDto> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class HistoryResponseDto(
|
||||
val items: List<BackendStationDto> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val limit: Int = 50,
|
||||
val offset: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MagicLinkRequestDto(
|
||||
val email: String
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MagicLinkVerifyDto(
|
||||
val email: String,
|
||||
val code: String
|
||||
)
|
||||
@@ -4,15 +4,20 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NowPlayingItemDto(
|
||||
data class TrackDto(
|
||||
@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 NowPlayingItemDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("track") val track: TrackDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NowPlayingResponse(
|
||||
@SerialName("result") val result: List<NowPlayingItemDto>
|
||||
|
||||
@@ -8,12 +8,30 @@ 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
|
||||
@SerialName("tooltip") val tooltip: String? = null,
|
||||
@SerialName("sort") val sort: Int = 0,
|
||||
@SerialName("bg_image") val bgImage: String? = null,
|
||||
@SerialName("bg_image_mobile") val bgImageMobile: String? = null,
|
||||
@SerialName("icon_fill_colored") val iconFillColored: String? = null,
|
||||
@SerialName("stream_128") val stream128: String? = null,
|
||||
@SerialName("stream_320") val stream320: String? = null,
|
||||
@SerialName("stream_hls") val streamHls: String? = null,
|
||||
@SerialName("tags") val tags: List<TagDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TagDto(
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("name") val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StationsResult(
|
||||
@SerialName("stations") val stations: List<StationDto> = emptyList(),
|
||||
@SerialName("tags") val tags: List<TagDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StationsResponse(
|
||||
@SerialName("result") val result: List<StationDto>
|
||||
@SerialName("result") val result: StationsResult
|
||||
)
|
||||
|
||||
10
app/src/main/java/com/radiola/data/remote/dto/UserDto.kt
Normal file
10
app/src/main/java/com/radiola/data/remote/dto/UserDto.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val name: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserSettingsDto(
|
||||
val theme: String? = null,
|
||||
val language: String? = null,
|
||||
val autoPlay: Boolean? = null,
|
||||
val showOffline: Boolean? = null,
|
||||
val sleepTimerMinutes: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.data.remote.dto.MagicLinkRequestDto
|
||||
import com.radiola.data.remote.dto.MagicLinkVerifyDto
|
||||
import com.radiola.domain.model.User
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun requestMagicLink(email: String): Result<Unit> = try {
|
||||
api.requestMagicLink(MagicLinkRequestDto(email))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
override suspend fun verifyMagicLink(email: String, code: String): Result<User> = try {
|
||||
val response = api.verifyMagicLink(MagicLinkVerifyDto(email, code))
|
||||
tokenDataStore.saveToken(
|
||||
token = response.accessToken,
|
||||
userId = response.user.id,
|
||||
email = response.user.email,
|
||||
name = response.user.name
|
||||
)
|
||||
Result.success(
|
||||
User(
|
||||
id = response.user.id,
|
||||
email = response.user.email,
|
||||
name = response.user.name
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
override fun isLoggedIn(): Flow<Boolean> = tokenDataStore.isLoggedIn
|
||||
|
||||
override fun currentUser(): Flow<User?> = combine(
|
||||
tokenDataStore.userId,
|
||||
tokenDataStore.userEmail,
|
||||
tokenDataStore.userName
|
||||
) { id, email, name ->
|
||||
if (id != null && email != null) {
|
||||
User(id = id, email = email, name = name)
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenDataStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,18 @@ class FavoritesRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFavoriteIds(): Flow<Set<Int>> {
|
||||
return db.stationDao().getFavoriteIds().map { it.toSet() }
|
||||
}
|
||||
|
||||
override suspend fun addFavorite(station: Station) {
|
||||
db.stationDao().setFavorite(station.id, true)
|
||||
}
|
||||
|
||||
override suspend fun addFavorite(stationId: Int) {
|
||||
db.stationDao().setFavorite(stationId, true)
|
||||
}
|
||||
|
||||
override suspend fun removeFavorite(stationId: Int) {
|
||||
db.stationDao().setFavorite(stationId, false)
|
||||
}
|
||||
@@ -42,6 +50,7 @@ class FavoritesRepositoryImpl @Inject constructor(
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
tags = tags.split(",").filter { it.isNotBlank() },
|
||||
sortOrder = sortOrder
|
||||
sortOrder = sortOrder,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@ class NowPlayingRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi
|
||||
) : NowPlayingRepository {
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<Map<String, Track>>(emptyMap())
|
||||
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
|
||||
|
||||
override fun getNowPlaying(stationPrefix: String): Flow<Track?> {
|
||||
return _nowPlaying.map { it[stationPrefix] }
|
||||
override fun getNowPlaying(stationId: Int): Flow<Track?> {
|
||||
return _nowPlaying.map { it[stationId] }
|
||||
}
|
||||
|
||||
override fun getAllNowPlaying(): Flow<Map<String, Track>> = _nowPlaying
|
||||
override fun getAllNowPlaying(): Flow<Map<Int, Track>> = _nowPlaying
|
||||
|
||||
override suspend fun refreshNowPlaying(): Result<Unit> {
|
||||
return try {
|
||||
val response = api.getNowPlaying()
|
||||
val map = response.result.associate { it.prefix to it.toDomain() }
|
||||
val map = response.result.associate { it.id to it.toDomain() }
|
||||
_nowPlaying.value = map
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.radiola.data.local.AppDatabase
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.service.RecordingService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class RecordingRepositoryImpl @Inject constructor(
|
||||
private val db: AppDatabase,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
@ApplicationContext private val context: Context
|
||||
) : RecordingRepository {
|
||||
|
||||
private val _isRecording = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||
|
||||
private var recordingJob: Job? = null
|
||||
private var currentCall: okhttp3.Call? = null
|
||||
private var currentRecordingId: Long? = null
|
||||
|
||||
override fun getRecordings(): Flow<List<Recording>> {
|
||||
return db.recordingDao().getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startRecording(station: Station, track: Track?) {
|
||||
if (_isRecording.value) return
|
||||
|
||||
val id = System.currentTimeMillis()
|
||||
currentRecordingId = id
|
||||
|
||||
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
|
||||
dir.mkdirs()
|
||||
|
||||
val ext = when {
|
||||
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
|
||||
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
|
||||
else -> "audio"
|
||||
}
|
||||
val safeName = station.name.replace(Regex("[^a-zA-Z0-9а-яА-ЯёЁ]"), "_").take(30)
|
||||
val file = File(dir, "${safeName}_${id}.$ext")
|
||||
|
||||
val entity = RecordingEntity(
|
||||
id = id,
|
||||
stationName = station.name,
|
||||
stationId = station.id,
|
||||
filePath = file.absolutePath,
|
||||
startTime = id,
|
||||
endTime = null,
|
||||
trackName = track?.let { "${it.artist} - ${it.song}" },
|
||||
duration = null
|
||||
)
|
||||
db.recordingDao().insert(entity)
|
||||
_isRecording.value = true
|
||||
|
||||
// Start foreground service to keep process alive during recording
|
||||
val serviceIntent = Intent(context, RecordingService::class.java).apply {
|
||||
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
|
||||
recordingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
var output: FileOutputStream? = null
|
||||
try {
|
||||
val request = Request.Builder().url(station.streamUrl).build()
|
||||
val call = okHttpClient.newCall(request)
|
||||
currentCall = call
|
||||
val response = call.execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("RecordingRepo", "HTTP error: ${response.code}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
output = FileOutputStream(file)
|
||||
val input = response.body?.byteStream()
|
||||
if (input == null) {
|
||||
Log.e("RecordingRepo", "Empty response body")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (isActive) {
|
||||
bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
input.close()
|
||||
} catch (e: IOException) {
|
||||
if (e.message?.contains("Canceled") == true) {
|
||||
Log.d("RecordingRepo", "Recording cancelled normally")
|
||||
} else {
|
||||
Log.e("RecordingRepo", "Recording error", e)
|
||||
}
|
||||
} finally {
|
||||
try { output?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stopRecording() {
|
||||
currentCall?.cancel()
|
||||
currentCall = null
|
||||
recordingJob?.cancelAndJoin()
|
||||
recordingJob = null
|
||||
_isRecording.value = false
|
||||
|
||||
// Stop foreground service
|
||||
context.stopService(Intent(context, RecordingService::class.java))
|
||||
|
||||
currentRecordingId?.let { id ->
|
||||
val endTime = System.currentTimeMillis()
|
||||
val duration = endTime - id
|
||||
try {
|
||||
db.recordingDao().updateEndTime(id, endTime, duration)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RecordingRepo", "Failed to update recording end time", e)
|
||||
}
|
||||
}
|
||||
currentRecordingId = null
|
||||
}
|
||||
|
||||
override suspend fun deleteRecording(id: Long) {
|
||||
val entity = db.recordingDao().getById(id)
|
||||
entity?.let {
|
||||
try { File(it.filePath).delete() } catch (_: Exception) {}
|
||||
}
|
||||
db.recordingDao().deleteById(id)
|
||||
}
|
||||
|
||||
private fun RecordingEntity.toDomain(): Recording = Recording(
|
||||
id = id,
|
||||
stationName = stationName,
|
||||
stationId = stationId,
|
||||
filePath = filePath,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
trackName = trackName,
|
||||
duration = duration
|
||||
)
|
||||
}
|
||||
@@ -32,20 +32,21 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
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 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 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>) =
|
||||
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 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 }
|
||||
override suspend fun setRecordingEnabled(enabled: Boolean) { dataStore.edit { it[RECORDING_ENABLED] = enabled } }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.LocalStationDataSource
|
||||
import com.radiola.data.local.AppDatabase
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class StationRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi,
|
||||
private val db: AppDatabase
|
||||
private val db: AppDatabase,
|
||||
private val localDataSource: LocalStationDataSource
|
||||
) : StationRepository {
|
||||
|
||||
private val _tags = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
override fun getStations(): Flow<List<Station>> {
|
||||
return db.stationDao().getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
@@ -22,29 +29,73 @@ class StationRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun refreshStations(): Result<Unit> {
|
||||
android.util.Log.d("StationRepo", "refreshStations() called")
|
||||
return try {
|
||||
val response = api.getStations()
|
||||
val entities = response.result.mapIndexed { index, dto ->
|
||||
val domain = dto.toDomain()
|
||||
// 1. Load local stations from assets
|
||||
val localStations = localDataSource.loadStations()
|
||||
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
|
||||
val localGroups = localDataSource.loadGroups()
|
||||
|
||||
// 2. Try to enrich with Record API data (covers, streams, tags)
|
||||
val apiResponse = try { api.getStations() } catch (e: Exception) { null }
|
||||
val apiStations = apiResponse?.result?.stations ?: emptyList()
|
||||
val apiTags = apiResponse?.result?.tags?.map { it.name } ?: emptyList()
|
||||
|
||||
// 3. Merge: local stations enriched with API data where IDs match
|
||||
val merged = localStations.map { local ->
|
||||
val apiStation = apiStations.find { it.id == local.id }
|
||||
if (apiStation != null) {
|
||||
val domain = apiStation.toDomain()
|
||||
local.copy(
|
||||
coverUrl = domain.coverUrl,
|
||||
streamUrl = domain.streamUrl,
|
||||
genre = local.genre, // keep group name for filtering
|
||||
tags = (local.tags + domain.tags).distinct(), // merge tags
|
||||
prefix = domain.prefix,
|
||||
source = "record"
|
||||
)
|
||||
} else {
|
||||
local
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB")
|
||||
val entities = merged.mapIndexed { index, station ->
|
||||
StationEntity(
|
||||
id = domain.id,
|
||||
name = domain.name,
|
||||
prefix = domain.prefix,
|
||||
streamUrl = domain.streamUrl,
|
||||
coverUrl = domain.coverUrl,
|
||||
genre = domain.genre,
|
||||
tags = domain.tags.joinToString(","),
|
||||
id = station.id,
|
||||
name = station.name,
|
||||
prefix = station.prefix,
|
||||
streamUrl = station.streamUrl,
|
||||
coverUrl = station.coverUrl,
|
||||
genre = station.genre,
|
||||
tags = station.tags.joinToString(","),
|
||||
sortOrder = index,
|
||||
source = station.source,
|
||||
isFavorite = false
|
||||
)
|
||||
}
|
||||
db.stationDao().insertAll(entities)
|
||||
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
|
||||
|
||||
// 5. Update tags: group names + API tags
|
||||
val groupNames = localGroups.map { it.name }.filter { it.isNotBlank() }
|
||||
val allTags = (groupNames + apiTags).distinct().sorted()
|
||||
db.tagDao().clearAll()
|
||||
db.tagDao().insertAll(allTags.map { TagEntity(it) })
|
||||
_tags.value = allTags
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("StationRepo", "refreshStations() failed", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTags(): Flow<List<String>> {
|
||||
return _tags.asStateFlow()
|
||||
}
|
||||
|
||||
override fun getStationById(id: Int): Flow<Station?> {
|
||||
return db.stationDao().getById(id).map { it?.toDomain() }
|
||||
}
|
||||
@@ -57,6 +108,7 @@ class StationRepositoryImpl @Inject constructor(
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
tags = tags.split(",").filter { it.isNotBlank() },
|
||||
sortOrder = sortOrder
|
||||
sortOrder = sortOrder,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
116
app/src/main/java/com/radiola/data/repository/StationTester.kt
Normal file
116
app/src/main/java/com/radiola/data/repository/StationTester.kt
Normal file
@@ -0,0 +1,116 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.StationTestResult
|
||||
import com.radiola.domain.model.StationTestStatus
|
||||
import com.radiola.domain.model.Track
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class StationTester @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient
|
||||
) {
|
||||
|
||||
suspend fun test(station: Station, nowPlaying: Track?): StationTestResult {
|
||||
val testClient = okHttpClient.newBuilder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(station.streamUrl)
|
||||
.header("Icy-Metadata", "1")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
testClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.OFFLINE,
|
||||
httpCode = response.code,
|
||||
errorMessage = "HTTP ${response.code}"
|
||||
)
|
||||
}
|
||||
|
||||
val contentType = response.header("Content-Type")
|
||||
val hasAudio = contentType?.startsWith("audio") == true
|
||||
val metaint = response.header("icy-metaint")?.toIntOrNull()
|
||||
val hasIcy = metaint != null
|
||||
|
||||
val icyTitle = if (hasIcy && metaint != null) {
|
||||
readIcyTitle(response, metaint)
|
||||
} else null
|
||||
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = when {
|
||||
icyTitle != null || nowPlaying != null -> StationTestStatus.OK
|
||||
hasAudio -> StationTestStatus.OK_NO_META
|
||||
else -> StationTestStatus.OK_NO_META
|
||||
},
|
||||
httpCode = response.code,
|
||||
contentType = contentType,
|
||||
hasIcyMetadata = hasIcy,
|
||||
icyTitle = icyTitle,
|
||||
hasNowPlaying = nowPlaying != null,
|
||||
nowPlayingTrack = nowPlaying?.let { "${it.artist} - ${it.song}" },
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.OFFLINE,
|
||||
errorMessage = e.message ?: "Network error"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
StationTestResult(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
streamUrl = station.streamUrl,
|
||||
status = StationTestStatus.ERROR,
|
||||
errorMessage = e.message ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readIcyTitle(response: okhttp3.Response, metaint: Int): String? {
|
||||
return try {
|
||||
val source = response.body?.source() ?: return null
|
||||
val buffer = Buffer()
|
||||
|
||||
var skipped = 0L
|
||||
while (skipped < metaint) {
|
||||
val toSkip = (metaint - skipped).coerceAtMost(8192)
|
||||
val actual = source.read(buffer, toSkip)
|
||||
if (actual == -1L) return null
|
||||
skipped += actual
|
||||
}
|
||||
buffer.clear()
|
||||
|
||||
val metaLengthByte = source.readByte().toInt() and 0xFF
|
||||
if (metaLengthByte == 0) return null
|
||||
|
||||
val metaBytes = source.readByteArray((metaLengthByte * 16).toLong())
|
||||
val metadata = String(metaBytes, Charsets.UTF_8).trim('\u0000')
|
||||
|
||||
val regex = Regex("StreamTitle='([^']*)'")
|
||||
regex.find(metadata)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.TokenDataStore
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SyncRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi,
|
||||
private val tokenDataStore: TokenDataStore
|
||||
) : SyncRepository {
|
||||
|
||||
private fun isLoggedIn(): Boolean = tokenDataStore.currentToken != null
|
||||
|
||||
override suspend fun pushFavorite(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.addFavorite(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeFavorite(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.removeFavorite(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pushHistory(stationId: Int): Result<Unit> {
|
||||
if (!isLoggedIn()) return Result.success(Unit)
|
||||
return try {
|
||||
api.addHistory(stationId.toString())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchRemoteFavorites(): Result<List<Int>> {
|
||||
if (!isLoggedIn()) return Result.success(emptyList())
|
||||
return try {
|
||||
val stations = api.getFavorites()
|
||||
Result.success(stations.map { it.stationId })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchRemoteHistory(): Result<List<Int>> {
|
||||
if (!isLoggedIn()) return Result.success(emptyList())
|
||||
return try {
|
||||
val response = api.getHistory()
|
||||
Result.success(response.items.map { it.stationId })
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,23 @@ package com.radiola.deeplink
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.Track
|
||||
|
||||
object DeeplinkNavigator {
|
||||
|
||||
fun openSearch(context: Context, track: Track, service: DeeplinkService) {
|
||||
Log.d("DeeplinkNavigator", "openSearch: artist=${track.artist}, song=${track.song}, service=${service.displayName}")
|
||||
val url = service.buildSearchUrl(track.artist, track.song)
|
||||
Log.d("DeeplinkNavigator", "url=$url")
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
|
||||
try {
|
||||
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
|
||||
} catch (e: Exception) {
|
||||
Log.e("DeeplinkNavigator", "Failed to open deeplink", e)
|
||||
Toast.makeText(context, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,27 @@ 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.local.LocalStationDataSource
|
||||
import com.radiola.data.local.MIGRATION_1_2
|
||||
import com.radiola.data.local.MIGRATION_2_3
|
||||
import com.radiola.data.local.MIGRATION_3_4
|
||||
import com.radiola.data.remote.AuthInterceptor
|
||||
import com.radiola.data.remote.RecordApi
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.data.repository.AuthRepositoryImpl
|
||||
import com.radiola.data.repository.FavoritesRepositoryImpl
|
||||
import com.radiola.data.repository.NowPlayingRepositoryImpl
|
||||
import com.radiola.data.repository.RecordingRepositoryImpl
|
||||
import com.radiola.data.repository.SettingsRepositoryImpl
|
||||
import com.radiola.data.repository.StationRepositoryImpl
|
||||
import com.radiola.data.repository.SyncRepositoryImpl
|
||||
import com.radiola.data.repository.TrackHistoryRepositoryImpl
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
@@ -25,6 +36,8 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -40,7 +53,9 @@ object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
fun provideBaseOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
})
|
||||
@@ -48,7 +63,18 @@ object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||
@Named("radiolaClient")
|
||||
fun provideRadiolaOkHttpClient(
|
||||
baseClient: OkHttpClient,
|
||||
authInterceptor: AuthInterceptor
|
||||
): OkHttpClient = baseClient.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("record")
|
||||
fun provideRecordRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://www.radiorecord.ru/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
@@ -56,14 +82,38 @@ object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecordApi(retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)
|
||||
@Named("radiola")
|
||||
fun provideRadiolaRetrofit(
|
||||
@Named("radiolaClient") okHttpClient: OkHttpClient,
|
||||
json: Json
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("http://121.127.37.212:3000/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRadiolaApi(@Named("radiola") retrofit: Retrofit): RadiolaApi = retrofit.create(RadiolaApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLocalStationDataSource(
|
||||
@ApplicationContext context: Context,
|
||||
json: Json
|
||||
): LocalStationDataSource = LocalStationDataSource(context, json)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStationRepository(impl: StationRepositoryImpl): StationRepository = impl
|
||||
@@ -83,4 +133,16 @@ object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(impl: AuthRepositoryImpl): AuthRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
|
||||
}
|
||||
|
||||
12
app/src/main/java/com/radiola/domain/model/Recording.kt
Normal file
12
app/src/main/java/com/radiola/domain/model/Recording.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
data class Recording(
|
||||
val id: Long,
|
||||
val stationName: String,
|
||||
val stationId: Int,
|
||||
val filePath: String,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val trackName: String?,
|
||||
val duration: Long?
|
||||
)
|
||||
@@ -8,5 +8,6 @@ data class Station(
|
||||
val coverUrl: String,
|
||||
val genre: String,
|
||||
val tags: List<String>,
|
||||
val sortOrder: Int
|
||||
val sortOrder: Int,
|
||||
val source: String = "record"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
enum class StationTestStatus {
|
||||
OK, // Stream OK + has metadata (Icy or NowPlaying)
|
||||
OK_NO_META, // Stream OK, no metadata
|
||||
OFFLINE, // HTTP error / timeout / no response
|
||||
ERROR // Other exception
|
||||
}
|
||||
|
||||
data class StationTestResult(
|
||||
val stationId: Int,
|
||||
val stationName: String,
|
||||
val streamUrl: String,
|
||||
val status: StationTestStatus,
|
||||
val httpCode: Int? = null,
|
||||
val contentType: String? = null,
|
||||
val hasIcyMetadata: Boolean = false,
|
||||
val icyTitle: String? = null,
|
||||
val hasNowPlaying: Boolean = false,
|
||||
val nowPlayingTrack: String? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
7
app/src/main/java/com/radiola/domain/model/User.kt
Normal file
7
app/src/main/java/com/radiola/domain/model/User.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val name: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun requestMagicLink(email: String): Result<Unit>
|
||||
suspend fun verifyMagicLink(email: String, code: String): Result<User>
|
||||
fun isLoggedIn(): Flow<Boolean>
|
||||
fun currentUser(): Flow<User?>
|
||||
suspend fun logout()
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface FavoritesRepository {
|
||||
fun getFavorites(): Flow<List<Station>>
|
||||
fun getFavoriteIds(): Flow<Set<Int>>
|
||||
suspend fun addFavorite(station: Station)
|
||||
suspend fun addFavorite(stationId: Int)
|
||||
suspend fun removeFavorite(stationId: Int)
|
||||
fun isFavorite(stationId: Int): Flow<Boolean>
|
||||
suspend fun reorderFavorites(orderedIds: List<Int>)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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>>
|
||||
fun getNowPlaying(stationId: Int): Flow<Track?>
|
||||
fun getAllNowPlaying(): Flow<Map<Int, Track>>
|
||||
suspend fun refreshNowPlaying(): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface RecordingRepository {
|
||||
val isRecording: StateFlow<Boolean>
|
||||
fun getRecordings(): Flow<List<Recording>>
|
||||
suspend fun startRecording(station: Station, track: Track?)
|
||||
suspend fun stopRecording()
|
||||
suspend fun deleteRecording(id: Long)
|
||||
}
|
||||
@@ -7,4 +7,5 @@ interface StationRepository {
|
||||
fun getStations(): Flow<List<Station>>
|
||||
suspend fun refreshStations(): Result<Unit>
|
||||
fun getStationById(id: Int): Flow<Station?>
|
||||
fun getTags(): Flow<List<String>>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.Station
|
||||
|
||||
interface SyncRepository {
|
||||
suspend fun pushFavorite(stationId: Int): Result<Unit>
|
||||
suspend fun removeFavorite(stationId: Int): Result<Unit>
|
||||
suspend fun pushHistory(stationId: Int): Result<Unit>
|
||||
suspend fun fetchRemoteFavorites(): Result<List<Int>>
|
||||
suspend fun fetchRemoteHistory(): Result<List<Int>>
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import javax.inject.Inject
|
||||
class GetNowPlayingUseCase @Inject constructor(
|
||||
private val nowPlayingRepository: NowPlayingRepository
|
||||
) {
|
||||
operator fun invoke(stationPrefix: String): Flow<Track?> {
|
||||
return nowPlayingRepository.getNowPlaying(stationPrefix)
|
||||
operator fun invoke(stationId: Int): Flow<Track?> {
|
||||
return nowPlayingRepository.getNowPlaying(stationId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.radiola.domain.usecase
|
||||
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class RefreshStationsUseCase @Inject constructor(
|
||||
private val stationRepository: StationRepository
|
||||
) {
|
||||
suspend operator fun invoke(): Result<Unit> = stationRepository.refreshStations()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.radiola.domain.usecase
|
||||
|
||||
import com.radiola.data.repository.StationTester
|
||||
import com.radiola.domain.model.StationTestResult
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class TestStationsUseCase @Inject constructor(
|
||||
private val stationRepository: StationRepository,
|
||||
private val nowPlayingRepository: NowPlayingRepository,
|
||||
private val stationTester: StationTester
|
||||
) {
|
||||
|
||||
data class Progress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val result: StationTestResult? = null
|
||||
)
|
||||
|
||||
operator fun invoke(): Flow<Progress> = flow {
|
||||
val stations = stationRepository.getStations().first()
|
||||
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
val nowPlayingMap = nowPlayingRepository.getAllNowPlaying().first()
|
||||
|
||||
stations.forEachIndexed { index, station ->
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
stationTester.test(station, nowPlayingMap[station.id])
|
||||
}
|
||||
emit(Progress(current = index + 1, total = stations.size, result = result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetAuthStateUseCase @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<Boolean> = repository.isLoggedIn()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.model.User
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetCurrentUserUseCase @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<User?> = repository.currentUser()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutUseCase @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) {
|
||||
suspend operator fun invoke() = repository.logout()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushFavoriteUseCase @Inject constructor(
|
||||
private val syncRepository: SyncRepository
|
||||
) {
|
||||
suspend operator fun invoke(stationId: Int, isAdding: Boolean): Result<Unit> =
|
||||
if (isAdding) syncRepository.pushFavorite(stationId)
|
||||
else syncRepository.removeFavorite(stationId)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushHistoryUseCase @Inject constructor(
|
||||
private val syncRepository: SyncRepository
|
||||
) {
|
||||
suspend operator fun invoke(stationId: Int): Result<Unit> =
|
||||
syncRepository.pushHistory(stationId)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class RequestMagicLinkUseCase @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) {
|
||||
suspend operator fun invoke(email: String): Result<Unit> =
|
||||
repository.requestMagicLink(email)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncFavoritesUseCase @Inject constructor(
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val syncRepository: SyncRepository
|
||||
) {
|
||||
suspend operator fun invoke() {
|
||||
syncRepository.fetchRemoteFavorites()
|
||||
.onSuccess { remoteIds ->
|
||||
val localIds = favoritesRepository.getFavoriteIds().first()
|
||||
// Add remote favorites that are missing locally
|
||||
remoteIds.forEach { id ->
|
||||
if (id !in localIds) {
|
||||
favoritesRepository.addFavorite(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.radiola.domain.usecase.auth
|
||||
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class VerifyMagicLinkUseCase @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) {
|
||||
suspend operator fun invoke(email: String, code: String) =
|
||||
repository.verifyMagicLink(email, code)
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.common.Player
|
||||
import android.util.Log
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -22,7 +32,45 @@ class PlayerController @Inject constructor(
|
||||
private val _currentStationPrefix = MutableStateFlow<String?>(null)
|
||||
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
|
||||
|
||||
val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
private val _icyTitle = MutableStateFlow<String?>(null)
|
||||
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
var onSkipToNext: (() -> Unit)? = null
|
||||
var onSkipToPrevious: (() -> Unit)? = null
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
val addedPlayback = addedDevices?.any { it.isPlaybackDevice() } == true
|
||||
if (addedPlayback && _currentStationPrefix.value != null && !exoPlayer.isPlaying) {
|
||||
Log.d("PlayerController", "Playback device connected → resume")
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||
val removedPlayback = removedDevices?.any { it.isPlaybackDevice() } == true
|
||||
if (removedPlayback && exoPlayer.isPlaying) {
|
||||
Log.d("PlayerController", "Playback device removed → pause")
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioDeviceInfo.isPlaybackDevice(): Boolean {
|
||||
return isSink && (
|
||||
type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||||
type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
|
||||
type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
|
||||
type == AudioDeviceInfo.TYPE_USB_HEADSET ||
|
||||
type == AudioDeviceInfo.TYPE_USB_DEVICE ||
|
||||
type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
|
||||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER
|
||||
)
|
||||
}
|
||||
|
||||
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
@@ -37,27 +85,106 @@ class PlayerController @Inject constructor(
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
_isPlaying.value = playing
|
||||
}
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
val title = mediaMetadata.title?.toString()
|
||||
if (!title.isNullOrBlank()) {
|
||||
Log.d("PlayerController", "MediaMetadata title: $title")
|
||||
_icyTitle.value = title
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: Metadata) {
|
||||
Log.d("PlayerController", "onMetadata called, length=${metadata.length()}")
|
||||
for (i in 0 until metadata.length()) {
|
||||
val entry = metadata.get(i)
|
||||
Log.d("PlayerController", "Metadata entry[$i]: ${entry::class.java.simpleName}")
|
||||
when (entry) {
|
||||
is IcyInfo -> {
|
||||
Log.d("PlayerController", "IcyInfo title='${entry.title}', url='${entry.url}', raw='${entry.rawMetadata}'")
|
||||
entry.title?.let {
|
||||
if (it.isNotBlank()) {
|
||||
_icyTitle.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun play(url: String, stationPrefix: String) {
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
val player: Player = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands()
|
||||
.buildUpon()
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun seekToNextMediaItem() {
|
||||
onSkipToNext?.invoke() ?: super.seekToNextMediaItem()
|
||||
}
|
||||
|
||||
override fun seekToPreviousMediaItem() {
|
||||
onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
|
||||
}
|
||||
|
||||
fun play(url: String, stationPrefix: String, stationName: String) {
|
||||
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
|
||||
_icyTitle.value = null
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(stationName)
|
||||
.setArtist("")
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.play()
|
||||
_currentStationPrefix.value = stationPrefix
|
||||
}
|
||||
|
||||
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
|
||||
val currentMediaItem = exoPlayer.currentMediaItem ?: return
|
||||
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
|
||||
val updatedMediaItem = currentMediaItem.buildUpon()
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(song)
|
||||
.setArtist("$artist • $stationName")
|
||||
.setAlbumTitle(stationName)
|
||||
.setArtworkUri(artworkUri)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
exoPlayer.replaceMediaItem(0, updatedMediaItem)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
fun play() {
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
exoPlayer.stop()
|
||||
_currentStationPrefix.value = null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class PlayerService : MediaSessionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mediaSession = MediaSession.Builder(this, playerController.exoPlayer)
|
||||
mediaSession = MediaSession.Builder(this, playerController.player)
|
||||
.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
@@ -41,10 +41,7 @@ class PlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
63
app/src/main/java/com/radiola/service/RecordingService.kt
Normal file
63
app/src/main/java/com/radiola/service/RecordingService.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.radiola.R
|
||||
|
||||
class RecordingService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "recording_channel"
|
||||
const val NOTIFICATION_ID = 2
|
||||
const val EXTRA_STATION_NAME = "station_name"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val stationName = intent?.getStringExtra(EXTRA_STATION_NAME) ?: "Радио"
|
||||
val notification = buildNotification(stationName)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Запись радио",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Уведомления о записи радиопотока"
|
||||
setSound(null, null)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(stationName: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Идёт запись")
|
||||
.setContentText(stationName)
|
||||
.setSmallIcon(R.drawable.ic_play)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
158
app/src/main/java/com/radiola/ui/auth/AuthScreen.kt
Normal file
158
app/src/main/java/com/radiola/ui/auth/AuthScreen.kt
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.radiola.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
onAuthSuccess: () -> Unit,
|
||||
onSkip: (() -> Unit)? = null,
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val email by viewModel.email.collectAsState()
|
||||
var code by remember { mutableStateOf("") }
|
||||
var showCodeInput by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is AuthViewModel.AuthState.CodeSent -> showCodeInput = true
|
||||
is AuthViewModel.AuthState.Success -> onAuthSuccess()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Вход в radiOLA") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (showCodeInput) "Введите код из письма" else "Добро пожаловать",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (showCodeInput) "Мы отправили 6-значный код на ваш email" else "Войдите, чтобы синхронизировать избранное и историю между устройствами",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
if (!showCodeInput) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = viewModel::onEmailChange,
|
||||
label = { Text("Email") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { viewModel.requestCode() }),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.requestCode() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state !is AuthViewModel.AuthState.Loading
|
||||
) {
|
||||
if (state is AuthViewModel.AuthState.Loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text("Получить код")
|
||||
}
|
||||
}
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Продолжить без входа")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Код отправлен на $email",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { if (it.length <= 6) code = it.uppercase() },
|
||||
label = { Text("Код подтверждения") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
viewModel.verifyCode(code)
|
||||
}),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.verifyCode(code) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state !is AuthViewModel.AuthState.Loading && code.length == 6
|
||||
) {
|
||||
if (state is AuthViewModel.AuthState.Loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text("Войти")
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
showCodeInput = false
|
||||
code = ""
|
||||
viewModel.dismissError()
|
||||
}
|
||||
) {
|
||||
Text("Отправить код повторно")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state is AuthViewModel.AuthState.Error) {
|
||||
val errorMessage = (state as AuthViewModel.AuthState.Error).message
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissError,
|
||||
title = { Text("Ошибка") },
|
||||
text = { Text(errorMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::dismissError) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
70
app/src/main/java/com/radiola/ui/auth/AuthViewModel.kt
Normal file
70
app/src/main/java/com/radiola/ui/auth/AuthViewModel.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.radiola.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.usecase.auth.RequestMagicLinkUseCase
|
||||
import com.radiola.domain.usecase.auth.VerifyMagicLinkUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val requestMagicLinkUseCase: RequestMagicLinkUseCase,
|
||||
private val verifyMagicLinkUseCase: VerifyMagicLinkUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
sealed class AuthState {
|
||||
data object Idle : AuthState()
|
||||
data object Loading : AuthState()
|
||||
data object CodeSent : AuthState()
|
||||
data object Success : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.Idle)
|
||||
val state: StateFlow<AuthState> = _state
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
fun onEmailChange(value: String) {
|
||||
_email.value = value
|
||||
}
|
||||
|
||||
fun requestCode() {
|
||||
val email = _email.value.trim()
|
||||
if (email.isBlank() || !email.contains("@")) {
|
||||
_state.value = AuthState.Error("Введите корректный email")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = AuthState.Loading
|
||||
requestMagicLinkUseCase(email)
|
||||
.onSuccess { _state.value = AuthState.CodeSent }
|
||||
.onFailure { _state.value = AuthState.Error(it.message ?: "Ошибка отправки") }
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCode(code: String) {
|
||||
val email = _email.value.trim()
|
||||
if (code.length != 6) {
|
||||
_state.value = AuthState.Error("Код должен содержать 6 символов")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = AuthState.Loading
|
||||
verifyMagicLinkUseCase(email, code)
|
||||
.onSuccess { _state.value = AuthState.Success }
|
||||
.onFailure { _state.value = AuthState.Error(it.message ?: "Неверный код") }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
if (_state.value is AuthState.Error) {
|
||||
_state.value = AuthState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ 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.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.Brush
|
||||
@@ -16,12 +19,16 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.radiola.domain.model.Station
|
||||
|
||||
@Composable
|
||||
fun StationCard(
|
||||
station: Station,
|
||||
isFavorite: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
@@ -31,34 +38,54 @@ fun StationCard(
|
||||
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)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
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
|
||||
)
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onFavoriteClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Heart,
|
||||
contentDescription = if (isFavorite) "В избранном" else "Добавить в избранное",
|
||||
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = station.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.ui.components.EmptyState
|
||||
import com.radiola.ui.components.StationCard
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -21,6 +22,7 @@ fun FavoritesScreen(
|
||||
viewModel: FavoritesViewModel = hiltViewModel()
|
||||
) {
|
||||
val favorites by viewModel.favorites.collectAsState()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -50,7 +52,9 @@ fun FavoritesScreen(
|
||||
items(favorites, key = { it.id }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
onClick = { onStationClick(station) }
|
||||
isFavorite = favoriteIds.contains(station.id),
|
||||
onClick = { onStationClick(station) },
|
||||
onFavoriteClick = { viewModel.toggleFavorite(station) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,43 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.PushFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FavoritesViewModel @Inject constructor(
|
||||
favoritesRepository: FavoritesRepository
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val pushFavoriteUseCase: PushFavoriteUseCase,
|
||||
private val syncFavoritesUseCase: SyncFavoritesUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
|
||||
.map { list -> list.map { it.id }.toSet() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
syncFavoritesUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
viewModelScope.launch {
|
||||
val currentlyFavorite = favoriteIds.value.contains(station.id)
|
||||
toggleFavoriteUseCase(station)
|
||||
pushFavoriteUseCase(station.id, !currentlyFavorite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
fun BottomNavBar(navController: NavController) {
|
||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||
NavigationBar {
|
||||
NavDestinations.items.forEach { destination ->
|
||||
NavDestinations.items.filter { it.showInBottomBar }.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(destination.icon, contentDescription = destination.labelRes) },
|
||||
label = { Text(destination.labelRes) },
|
||||
|
||||
@@ -4,20 +4,24 @@ 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.Mic
|
||||
import com.composables.icons.lucide.Radio
|
||||
import com.composables.icons.lucide.Settings
|
||||
|
||||
sealed class NavDestinations(
|
||||
val route: String,
|
||||
val labelRes: String,
|
||||
val icon: ImageVector
|
||||
val icon: ImageVector,
|
||||
val showInBottomBar: Boolean = true
|
||||
) {
|
||||
data object Stations : NavDestinations("stations", "Радио", Lucide.Home)
|
||||
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
|
||||
data object Favorites : NavDestinations("favorites", "Избранное", Lucide.Heart)
|
||||
data object History : NavDestinations("history", "История", Lucide.History)
|
||||
data object Recordings : NavDestinations("recordings", "Записи", Lucide.Mic)
|
||||
data object Settings : NavDestinations("settings", "Настройки", Lucide.Settings)
|
||||
data object Auth : NavDestinations("auth", "Вход", Lucide.Settings, showInBottomBar = false)
|
||||
|
||||
companion object {
|
||||
val items = listOf(Stations, Favorites, History, Settings)
|
||||
val items = listOf(Stations, Favorites, History, Recordings, Settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,21 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.Heart
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Pause
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.SkipBack
|
||||
import com.composables.icons.lucide.SkipForward
|
||||
import com.composables.icons.lucide.Circle
|
||||
import com.composables.icons.lucide.Square
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.deeplink.DeeplinkNavigator
|
||||
@@ -33,6 +40,12 @@ fun PlayerBottomSheet(
|
||||
track: Track?,
|
||||
isPlaying: Boolean,
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
isFavorite: Boolean,
|
||||
onToggleFavorite: () -> Unit,
|
||||
isRecording: Boolean,
|
||||
onToggleRecording: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PlayerViewModel = hiltViewModel()
|
||||
) {
|
||||
@@ -50,12 +63,39 @@ fun PlayerBottomSheet(
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
station?.let {
|
||||
Text(
|
||||
text = it.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF888888)
|
||||
)
|
||||
station?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = s.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF888888)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onToggleFavorite,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Heart,
|
||||
contentDescription = "Избранное",
|
||||
tint = if (isFavorite) Color(0xFFFF4081) else Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onToggleRecording,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isRecording) Lucide.Square else Lucide.Circle,
|
||||
contentDescription = if (isRecording) "Остановить запись" else "Запись",
|
||||
tint = if (isRecording) Color(0xFFFF5252) else Color(0xFFFF5252),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Box(
|
||||
@@ -96,8 +136,9 @@ fun PlayerBottomSheet(
|
||||
service = service,
|
||||
onClick = {
|
||||
track?.let { t ->
|
||||
Log.d("PlayerBottomSheet", "DeeplinkButton clicked, track=${t.artist} - ${t.song}")
|
||||
DeeplinkNavigator.openSearch(context, t, service)
|
||||
}
|
||||
} ?: Log.d("PlayerBottomSheet", "DeeplinkButton clicked but track is null")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -107,14 +148,22 @@ fun PlayerBottomSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ControlButton(size = 56.dp, onClick = { })
|
||||
ControlButton(
|
||||
size = 56.dp,
|
||||
icon = Lucide.SkipBack,
|
||||
onClick = onPrevious
|
||||
)
|
||||
ControlButton(
|
||||
size = 72.dp,
|
||||
isPlay = true,
|
||||
isPlaying = isPlaying,
|
||||
onClick = onPlayPause
|
||||
)
|
||||
ControlButton(size = 56.dp, onClick = { })
|
||||
ControlButton(
|
||||
size = 56.dp,
|
||||
icon = Lucide.SkipForward,
|
||||
onClick = onNext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +195,7 @@ private fun ControlButton(
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
isPlay: Boolean = false,
|
||||
isPlaying: Boolean = false,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -164,6 +214,13 @@ private fun ControlButton(
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(size * 0.4f)
|
||||
)
|
||||
} else if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(size * 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@ 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.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.domain.usecase.GetNowPlayingUseCase
|
||||
import com.radiola.domain.usecase.GetStationsUseCase
|
||||
import com.radiola.domain.usecase.SearchTrackInServiceUseCase
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.PushHistoryUseCase
|
||||
import com.radiola.service.PlayerController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -19,9 +27,15 @@ import javax.inject.Inject
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val stationRepository: StationRepository,
|
||||
private val nowPlayingRepository: NowPlayingRepository,
|
||||
private val getStationsUseCase: GetStationsUseCase,
|
||||
private val getNowPlayingUseCase: GetNowPlayingUseCase,
|
||||
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val trackHistoryRepository: TrackHistoryRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val recordingRepository: RecordingRepository,
|
||||
private val pushHistoryUseCase: PushHistoryUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||
@@ -36,31 +50,113 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
|
||||
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
|
||||
|
||||
private val _stations = MutableStateFlow<List<Station>>(emptyList())
|
||||
val stations: StateFlow<List<Station>> = _stations.asStateFlow()
|
||||
|
||||
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
|
||||
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
|
||||
|
||||
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
|
||||
|
||||
private var nowPlayingJob: Job? = null
|
||||
|
||||
init {
|
||||
playerController.onSkipToNext = { playNext() }
|
||||
playerController.onSkipToPrevious = { playPrevious() }
|
||||
viewModelScope.launch {
|
||||
getStationsUseCase().collect { _stations.value = it }
|
||||
}
|
||||
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
|
||||
_currentTrack
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { track ->
|
||||
trackHistoryRepository.addTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(station: Station, playlist: List<Station>? = null) {
|
||||
_currentStation.value = station
|
||||
_currentTrack.value = null
|
||||
_playlist.value = playlist ?: _stations.value
|
||||
playerController.play(station.streamUrl, station.prefix, station.name)
|
||||
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
||||
nowPlayingJob?.cancel()
|
||||
nowPlayingJob = viewModelScope.launch {
|
||||
// Polling loop for Record API now playing
|
||||
launch {
|
||||
while (true) {
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
delay(10_000)
|
||||
}
|
||||
}
|
||||
// Collect now playing for this station (API has priority: covers + accurate metadata)
|
||||
launch {
|
||||
getNowPlayingUseCase(station.id)
|
||||
.distinctUntilChanged()
|
||||
.collect { track ->
|
||||
if (track != null) {
|
||||
_currentTrack.value = track
|
||||
playerController.updateMetadata(
|
||||
track.song,
|
||||
track.artist,
|
||||
track.coverUrl ?: "",
|
||||
station.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: Icy metadata from stream for stations not in Record API
|
||||
launch {
|
||||
playerController.icyTitle
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { icyTitle ->
|
||||
// Only use Icy if no API track is currently active
|
||||
if (_currentTrack.value == null) {
|
||||
val track = parseIcyTitle(icyTitle)
|
||||
if (track != null) {
|
||||
_currentTrack.value = track
|
||||
playerController.updateMetadata(
|
||||
track.song,
|
||||
track.artist,
|
||||
"",
|
||||
station.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(station: Station) {
|
||||
_currentStation.value = station
|
||||
playerController.play(station.streamUrl, station.prefix)
|
||||
viewModelScope.launch {
|
||||
getNowPlayingUseCase(station.prefix).collect { track ->
|
||||
_currentTrack.value = track
|
||||
private fun parseIcyTitle(title: String?): Track? {
|
||||
if (title.isNullOrBlank()) return null
|
||||
val separators = listOf(" - ", " — ", " – ")
|
||||
for (sep in separators) {
|
||||
val parts = title.split(sep, limit = 2)
|
||||
if (parts.size == 2) {
|
||||
return Track(
|
||||
artist = parts[0].trim(),
|
||||
song = parts[1].trim(),
|
||||
coverUrl = null,
|
||||
stationName = _currentStation.value?.name ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
// No separator found: treat entire string as song title
|
||||
return Track(
|
||||
artist = "",
|
||||
song = title.trim(),
|
||||
coverUrl = null,
|
||||
stationName = _currentStation.value?.name ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
@@ -68,14 +164,49 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
playerController.exoPlayer.play()
|
||||
playerController.play()
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
if (isPlaying.value) pause() else resume()
|
||||
}
|
||||
|
||||
fun playNext() {
|
||||
val current = _currentStation.value ?: return
|
||||
val list = _playlist.value
|
||||
if (list.isEmpty()) return
|
||||
val index = list.indexOfFirst { it.id == current.id }
|
||||
val next = list.getOrNull((index + 1).mod(list.size))
|
||||
next?.let { play(it, list) }
|
||||
}
|
||||
|
||||
fun playPrevious() {
|
||||
val current = _currentStation.value ?: return
|
||||
val list = _playlist.value
|
||||
if (list.isEmpty()) return
|
||||
val index = list.indexOfFirst { it.id == current.id }
|
||||
val prev = list.getOrNull((index - 1).mod(list.size))
|
||||
prev?.let { play(it, list) }
|
||||
}
|
||||
|
||||
fun getDeeplinkUrl(track: Track, service: DeeplinkService): String {
|
||||
return searchTrackInServiceUseCase(track, service)
|
||||
}
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
viewModelScope.launch {
|
||||
toggleFavoriteUseCase(station)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
viewModelScope.launch {
|
||||
if (recordingRepository.isRecording.value) {
|
||||
recordingRepository.stopRecording()
|
||||
} else {
|
||||
val station = _currentStation.value ?: return@launch
|
||||
recordingRepository.startRecording(station, _currentTrack.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
195
app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt
Normal file
195
app/src/main/java/com/radiola/ui/recordings/RecordingsScreen.kt
Normal file
@@ -0,0 +1,195 @@
|
||||
package com.radiola.ui.recordings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.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.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.radiola.domain.model.Recording
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun RecordingsScreen(
|
||||
viewModel: RecordingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val recordings by viewModel.recordings.collectAsState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Записи",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
if (isRecording) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFFF5252).copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xFFFF5252))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Идёт запись...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFFFF5252)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recordings.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Нет записей",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(recordings, key = { it.id }) { recording ->
|
||||
RecordingItem(
|
||||
recording = recording,
|
||||
onPlay = {
|
||||
// TODO: play recording via external player or ExoPlayer
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
java.io.File(recording.filePath)
|
||||
),
|
||||
"audio/*"
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onDelete = { viewModel.deleteRecording(recording.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingItem(
|
||||
recording: Recording,
|
||||
onPlay: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
|
||||
val durationText = recording.duration?.let { ms ->
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(ms)
|
||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60
|
||||
String.format("%02d:%02d", minutes, seconds)
|
||||
} ?: "??:??"
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Play,
|
||||
contentDescription = "Воспроизвести",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = recording.stationName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = recording.trackName ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "${dateFormat.format(Date(recording.startTime))} • $durationText",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Trash2,
|
||||
contentDescription = "Удалить",
|
||||
tint = Color(0xFFFF5252)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.radiola.ui.recordings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RecordingsViewModel @Inject constructor(
|
||||
private val recordingRepository: RecordingRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val recordings: StateFlow<List<Recording>> = recordingRepository.getRecordings()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
|
||||
|
||||
fun deleteRecording(id: Long) {
|
||||
viewModelScope.launch {
|
||||
recordingRepository.deleteRecording(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,21 @@ package com.radiola.ui.settings
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.StationTestStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
@@ -20,7 +25,14 @@ fun SettingsScreen(
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||
val isTesting by viewModel.isTesting.collectAsState()
|
||||
val testProgress by viewModel.testProgress.collectAsState()
|
||||
val testTotal by viewModel.testTotal.collectAsState()
|
||||
val testResults by viewModel.testResults.collectAsState()
|
||||
val isLoggedIn by viewModel.isLoggedIn.collectAsState()
|
||||
val currentUser by viewModel.currentUser.collectAsState()
|
||||
val presets = listOf("Flat", "Rock", "Pop", "Jazz", "Bass")
|
||||
var showReport by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -39,6 +51,37 @@ fun SettingsScreen(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text("Профиль", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (isLoggedIn && currentUser != null) {
|
||||
Column {
|
||||
Text(
|
||||
text = currentUser?.email ?: "",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.logout() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Выйти")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onNavigateToAuth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Войти")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Divider()
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Таймер сна", style = MaterialTheme.typography.titleMedium)
|
||||
Slider(
|
||||
@@ -99,6 +142,94 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Divider()
|
||||
}
|
||||
item {
|
||||
Text("Тестирование станций", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (isTesting) {
|
||||
Column {
|
||||
LinearProgressIndicator(
|
||||
progress = { if (testTotal > 0) testProgress.toFloat() / testTotal else 0f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("Проверено $testProgress из $testTotal")
|
||||
}
|
||||
} else if (testResults.isNotEmpty()) {
|
||||
val ok = testResults.count { it.status == StationTestStatus.OK }
|
||||
val okNoMeta = testResults.count { it.status == StationTestStatus.OK_NO_META }
|
||||
val offline = testResults.count { it.status == StationTestStatus.OFFLINE }
|
||||
val error = testResults.count { it.status == StationTestStatus.ERROR }
|
||||
|
||||
Column {
|
||||
Text("Всего: ${testResults.size}")
|
||||
Text("Работают + метаданные: $ok", color = Color(0xFF4CAF50))
|
||||
Text("Работают без метаданных: $okNoMeta", color = Color(0xFFFF9800))
|
||||
Text("Оффлайн: $offline", color = Color(0xFFFF5252))
|
||||
Text("Ошибки: $error", color = Color(0xFFFF5252))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { showReport = true }) {
|
||||
Text("Подробный отчёт")
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.clearTestResults() }) {
|
||||
Text("Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startTesting() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Провести тестирование")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showReport) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showReport = false },
|
||||
title = { Text("Результаты тестирования") },
|
||||
text = {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
|
||||
items(testResults) { result ->
|
||||
val color = when (result.status) {
|
||||
StationTestStatus.OK -> Color(0xFF4CAF50)
|
||||
StationTestStatus.OK_NO_META -> Color(0xFFFF9800)
|
||||
StationTestStatus.OFFLINE -> Color(0xFFFF5252)
|
||||
StationTestStatus.ERROR -> Color(0xFFFF5252)
|
||||
}
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = result.stationName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
append("${result.status.name}")
|
||||
result.httpCode?.let { append(" | HTTP $it") }
|
||||
result.icyTitle?.let { append(" | Icy: $it") }
|
||||
result.nowPlayingTrack?.let { append(" | NP: $it") }
|
||||
result.errorMessage?.let { append(" | $it") }
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showReport = false }) {
|
||||
Text("Закрыть")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ package com.radiola.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.StationTestResult
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import com.radiola.domain.usecase.TestStationsUseCase
|
||||
import com.radiola.domain.usecase.auth.GetAuthStateUseCase
|
||||
import com.radiola.domain.usecase.auth.GetCurrentUserUseCase
|
||||
import com.radiola.domain.usecase.auth.LogoutUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -11,7 +16,11 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val testStationsUseCase: TestStationsUseCase,
|
||||
getAuthStateUseCase: GetAuthStateUseCase,
|
||||
getCurrentUserUseCase: GetCurrentUserUseCase,
|
||||
private val logoutUseCase: LogoutUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val sleepTimerMinutes: StateFlow<Int> = settingsRepository.getSleepTimerMinutes()
|
||||
@@ -26,6 +35,24 @@ class SettingsViewModel @Inject constructor(
|
||||
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val currentUser = getCurrentUserUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
private val _isTesting = MutableStateFlow(false)
|
||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
||||
|
||||
private val _testProgress = MutableStateFlow(0)
|
||||
val testProgress: StateFlow<Int> = _testProgress.asStateFlow()
|
||||
|
||||
private val _testTotal = MutableStateFlow(0)
|
||||
val testTotal: StateFlow<Int> = _testTotal.asStateFlow()
|
||||
|
||||
private val _testResults = MutableStateFlow<List<StationTestResult>>(emptyList())
|
||||
val testResults: StateFlow<List<StationTestResult>> = _testResults.asStateFlow()
|
||||
|
||||
fun setSleepTimer(minutes: Int) {
|
||||
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
|
||||
}
|
||||
@@ -45,4 +72,30 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setRecordingEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun startTesting() {
|
||||
viewModelScope.launch {
|
||||
_isTesting.value = true
|
||||
_testProgress.value = 0
|
||||
_testTotal.value = 0
|
||||
_testResults.value = emptyList()
|
||||
|
||||
val results = mutableListOf<StationTestResult>()
|
||||
testStationsUseCase().collect { progress ->
|
||||
_testProgress.value = progress.current
|
||||
_testTotal.value = progress.total
|
||||
progress.result?.let { results.add(it) }
|
||||
}
|
||||
_testResults.value = results
|
||||
_isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTestResults() {
|
||||
_testResults.value = emptyList()
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { logoutUseCase() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ package com.radiola.ui.stations
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
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.Alignment
|
||||
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.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -24,54 +27,68 @@ fun StationsScreen(
|
||||
val selectedTag by viewModel.selectedTag.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Радио") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
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),
|
||||
)
|
||||
when {
|
||||
isLoading && stations.isEmpty() -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
stations.isEmpty() -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(stations, key = { it.id }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
onClick = { onStationClick(station) }
|
||||
)
|
||||
EmptyState(message = error ?: "Станции не найдены")
|
||||
if (selectedTag != null) {
|
||||
Button(onClick = { viewModel.onTagSelected(null) }) {
|
||||
Text("Показать все")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::onSearchQueryChange,
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
FilterChips(
|
||||
tags = tags,
|
||||
selectedTag = selectedTag,
|
||||
onTagSelected = viewModel::onTagSelected,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
items(stations, key = { it.id }) { station ->
|
||||
StationCard(
|
||||
station = station,
|
||||
isFavorite = favoriteIds.contains(station.id),
|
||||
onClick = { onStationClick(station) },
|
||||
onFavoriteClick = { viewModel.toggleFavorite(station) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.radiola.ui.stations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import com.radiola.domain.usecase.GetStationsUseCase
|
||||
import com.radiola.domain.usecase.PlayStationUseCase
|
||||
import com.radiola.domain.usecase.RefreshStationsUseCase
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -14,8 +17,11 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class StationsViewModel @Inject constructor(
|
||||
private val getStationsUseCase: GetStationsUseCase,
|
||||
private val refreshStationsUseCase: RefreshStationsUseCase,
|
||||
private val playStationUseCase: PlayStationUseCase,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val stationRepository: StationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
@@ -37,7 +43,9 @@ class StationsViewModel @Inject constructor(
|
||||
) { allStations, query, tag ->
|
||||
allStations
|
||||
.filter { station ->
|
||||
tag == null || station.tags.contains(tag) || station.genre.equals(tag, ignoreCase = true)
|
||||
tag == null ||
|
||||
station.genre.equals(tag, ignoreCase = true) ||
|
||||
station.tags.any { it.equals(tag, ignoreCase = true) }
|
||||
}
|
||||
.filter { station ->
|
||||
query.isBlank() ||
|
||||
@@ -46,10 +54,22 @@ class StationsViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val tags: StateFlow<List<String>> = getStationsUseCase()
|
||||
.map { stations -> stations.flatMap { it.tags }.distinct().sorted() }
|
||||
val tags: StateFlow<List<String>> = stationRepository.getTags()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val favoriteIds: StateFlow<Set<Int>> = favoritesRepository.getFavorites()
|
||||
.map { list -> list.map { it.id }.toSet() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
refreshStationsUseCase()
|
||||
.onFailure { _error.value = it.localizedMessage ?: "Ошибка загрузки" }
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
android:id="@+id/widget_play_pause"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:src="@drawable/ic_play"
|
||||
android:contentDescription="@string/player_play" />
|
||||
</LinearLayout>
|
||||
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="recordings" path="Music/radiola_recordings/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user