feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

View File

@@ -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">

File diff suppressed because it is too large Load Diff

View File

@@ -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() }
)
}
}

View File

@@ -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
}

View File

@@ -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 ""
}
}

View 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 }
}
}

View File

@@ -0,0 +1,26 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.radiola.data.local.entity.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?
}

View File

@@ -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>>
}

View 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()
}

View File

@@ -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
)

View File

@@ -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?
)

View File

@@ -13,5 +13,6 @@ data class StationEntity(
val genre: String,
val tags: String,
val sortOrder: Int,
val source: String = "record",
val isFavorite: Boolean = false
)

View File

@@ -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
)

View File

@@ -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 = ""
)
}
}

View 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)
}
}
}

View 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
}

View File

@@ -0,0 +1,9 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class AuthResponseDto(
val accessToken: String,
val user: UserDto
)

View File

@@ -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
)

View File

@@ -0,0 +1,8 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class FavoritesResponseDto(
val favorites: List<BackendStationDto> = emptyList()
)

View File

@@ -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
)

View File

@@ -0,0 +1,8 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MagicLinkRequestDto(
val email: String
)

View File

@@ -0,0 +1,9 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MagicLinkVerifyDto(
val email: String,
val code: String
)

View File

@@ -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>

View File

@@ -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
)

View 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
)

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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) {

View File

@@ -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
)
}

View File

@@ -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 } }
}

View File

@@ -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
)
}

View 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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}

View 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?
)

View File

@@ -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"
)

View File

@@ -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
)

View File

@@ -0,0 +1,7 @@
package com.radiola.domain.model
data class User(
val id: String,
val email: String,
val name: String? = null
)

View File

@@ -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()
}

View File

@@ -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>)

View File

@@ -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>
}

View File

@@ -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)
}

View File

@@ -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>>
}

View File

@@ -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>>
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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))
}
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View 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()
}
}

View 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")
}
}
)
}
}

View 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
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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) }
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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) },

View File

@@ -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)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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)
}
}
}
}

View 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)
)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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("Закрыть")
}
}
)
}
}

View File

@@ -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() }
}
}

View File

@@ -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) }
)
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="recordings" path="Music/radiola_recordings/" />
</paths>