Compare commits
82 Commits
design/ui-
...
bdeb57c2ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdeb57c2ad | ||
|
|
38ddc96fab | ||
|
|
c0ee47b699 | ||
|
|
69682268f3 | ||
|
|
251809df33 | ||
|
|
8b1c65fa43 | ||
|
|
87dca7a6df | ||
|
|
78282e97ca | ||
|
|
645c2f14db | ||
|
|
a5d9a06c3f | ||
|
|
d63c1d4187 | ||
|
|
01729e0a52 | ||
|
|
2fcc065a18 | ||
|
|
07f56acf27 | ||
|
|
69f48d235e | ||
|
|
44807c9dba | ||
|
|
6eb614a729 | ||
|
|
e736c2393f | ||
|
|
0c01eaab2d | ||
|
|
ed926e0a9d | ||
|
|
d9acc0efb4 | ||
|
|
84c2b33473 | ||
|
|
f423344d13 | ||
|
|
861b0e2b8f | ||
|
|
4411d53a6c | ||
|
|
bda2c5b30f | ||
|
|
29cbe8997f | ||
|
|
5da077b698 | ||
|
|
2e970317f6 | ||
|
|
be6e1acfd8 | ||
|
|
5408bbd6c5 | ||
|
|
d504218d33 | ||
|
|
06cb6c16f1 | ||
|
|
fabf780450 | ||
|
|
53cd1601dc | ||
|
|
d9c83a83e9 | ||
|
|
1dfee941a0 | ||
|
|
1e00287486 | ||
|
|
900a4ad813 | ||
|
|
05e5538945 | ||
|
|
a46e437351 | ||
|
|
147b3ac81d | ||
|
|
4a33aa6fb5 | ||
|
|
4612a8f33c | ||
|
|
bd62016026 | ||
|
|
b2aff51c62 | ||
|
|
4a9622ca92 | ||
|
|
8d2c53c441 | ||
|
|
4697e27eb4 | ||
|
|
b6c0e92758 | ||
|
|
9268e14cc6 | ||
|
|
603e232dff | ||
|
|
b3912a9dca | ||
|
|
7df9b62403 | ||
|
|
fc63814f97 | ||
|
|
777f5d5082 | ||
|
|
6aa2588641 | ||
|
|
c77c131a09 | ||
|
|
5ffaf9a924 | ||
|
|
5b256a3421 | ||
|
|
320cac546b | ||
|
|
615e3435e3 | ||
|
|
4c4c6e05d8 | ||
|
|
4a45cb575e | ||
|
|
1ef60b6053 | ||
|
|
eeceb754ea | ||
|
|
b93bec028e | ||
|
|
72ecbae866 | ||
|
|
77772789bb | ||
|
|
99503fc77a | ||
|
|
b0c3dae20a | ||
|
|
5e22db5571 | ||
|
|
ee689ce380 | ||
|
|
9d115b148e | ||
|
|
ba32973beb | ||
|
|
5fd97d27fd | ||
|
|
32e5108d98 | ||
|
|
a50a108f63 | ||
|
|
fc9b23f62c | ||
|
|
eca0c49ad4 | ||
|
|
e190444577 | ||
|
|
d0e5f4e8c5 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,3 +25,7 @@ app/build/
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# Логотип: промежуточные генерации (тяжёлые), ключ — вне репо
|
||||
design/logos/gen/
|
||||
design/logos/ref_*.png
|
||||
|
||||
@@ -15,8 +15,8 @@ android {
|
||||
applicationId = "com.radiola"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 5
|
||||
versionName = "1.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -42,6 +42,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
@@ -61,6 +62,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -8,15 +8,25 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Авто-обновление: установка скачанного APK -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Держать CPU/Wi-Fi активными во время проигрывания при выключенном экране
|
||||
(иначе поток глохнет в фоне — особенно в машине по Bluetooth). -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Просить исключение из оптимизации батареи (Doze/ColorOS душат фоновое аудио). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".RadiolaApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher_round"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Radiola"
|
||||
android:usesCleartextTraffic="true"
|
||||
@@ -25,13 +35,59 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Radiola"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
android:theme="@style/Theme.Radiola.Splash"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize" />
|
||||
|
||||
<!-- Иконка лаунчера под цветовую тему: всегда включён ровно ОДИН alias.
|
||||
Переключается в рантайме (LauncherIconManager) при смене темы. -->
|
||||
<activity-alias android:name=".MainAliasForest" android:enabled="true" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_forest" android:roundIcon="@mipmap/ic_launcher_forest_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Forest">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasOcean" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_ocean" android:roundIcon="@mipmap/ic_launcher_ocean_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Ocean">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasSunset" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_sunset" android:roundIcon="@mipmap/ic_launcher_sunset_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Sunset">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasAmethyst" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_amethyst" android:roundIcon="@mipmap/ic_launcher_amethyst_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Amethyst">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasNeon" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_neon" android:roundIcon="@mipmap/ic_launcher_neon_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Neon">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasAmber" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_amber" android:roundIcon="@mipmap/ic_launcher_amber_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Amber">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasIce" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_ice" android:roundIcon="@mipmap/ic_launcher_ice_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Ice">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias android:name=".MainAliasRose" android:enabled="false" android:exported="true"
|
||||
android:targetActivity=".MainActivity" android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_rose" android:roundIcon="@mipmap/ic_launcher_rose_round"
|
||||
android:theme="@style/Theme.Radiola.Splash.Rose">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<service
|
||||
android:name=".service.PlayerService"
|
||||
@@ -57,6 +113,18 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".service.AlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.PlayerWidgetProvider"
|
||||
android:exported="false">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
package com.radiola
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -18,6 +20,7 @@ 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.charts.ChartsScreen
|
||||
import com.radiola.ui.components.MiniPlayer
|
||||
import com.radiola.ui.favorites.FavoritesScreen
|
||||
import com.radiola.ui.favorites.FavoritesViewModel
|
||||
@@ -29,6 +32,7 @@ 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.alarms.AlarmsScreen
|
||||
import com.radiola.ui.stations.StationsViewModel
|
||||
import com.radiola.service.PlayerService
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
@@ -43,15 +47,53 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var tokenDataStore: TokenDataStore
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: com.radiola.domain.repository.SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var launcherIconManager: com.radiola.util.LauncherIconManager
|
||||
|
||||
// После ответа на запрос уведомлений — просим исключение из оптимизации батареи.
|
||||
private val notifPermLauncher = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { maybeRequestBatteryExemption() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
startService(Intent(this, PlayerService::class.java))
|
||||
lifecycleScope.launch {
|
||||
tokenDataStore.preload()
|
||||
// Старт плеер-сервиса уводим с критического пути запуска — ускоряет
|
||||
// появление первого кадра (сплэша).
|
||||
startService(Intent(this@MainActivity, PlayerService::class.java))
|
||||
}
|
||||
ensureBackgroundPlaybackAllowed()
|
||||
enableEdgeToEdge()
|
||||
// Тему берём из быстрого SharedPreferences (его пишет LauncherIconManager при
|
||||
// смене темы) — синхронно и МГНОВЕННО, без блокировки первого кадра. Так сплэш
|
||||
// и приложение сразу нужного цвета, и тёмный системный сплэш не висит лишнее.
|
||||
val initialPaletteId = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
|
||||
.getString("icon_alias", "forest") ?: "forest"
|
||||
setContent {
|
||||
RadiolaTheme {
|
||||
// Выбранная цветовая тема (мгновенно перекрашивает всё приложение).
|
||||
val paletteId by settingsRepository.getThemePalette().collectAsState(initial = initialPaletteId)
|
||||
// Иконка лаунчера следует теме (срабатывает на старте и при смене темы).
|
||||
LaunchedEffect(paletteId) {
|
||||
launcherIconManager.applyIfNeeded(com.radiola.ui.theme.ThemePalette.fromId(paletteId))
|
||||
}
|
||||
RadiolaTheme(palette = com.radiola.ui.theme.ThemePalette.fromId(paletteId)) {
|
||||
// Сплэш рисуем на ПЕРВОМ (дешёвом) кадре; тяжёлый контент (ViewModels,
|
||||
// плеер) композим следующим кадром ПОД сплэшем — так логотип появляется
|
||||
// почти сразу, без долгого тёмного ожидания холодного старта.
|
||||
var showSplash by remember { mutableStateOf(true) }
|
||||
var contentReady by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { contentReady = true }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(1600)
|
||||
showSplash = false
|
||||
}
|
||||
|
||||
if (contentReady) {
|
||||
val navController = rememberNavController()
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var showPlayer by remember { mutableStateOf(false) }
|
||||
@@ -67,6 +109,18 @@ class MainActivity : ComponentActivity() {
|
||||
val isRecording by playerViewModel.isRecording.collectAsState()
|
||||
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
|
||||
|
||||
// --- Авто-обновление: проверяем версию на старте, показываем диалог ---
|
||||
var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
|
||||
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
|
||||
var updateProgress by remember { mutableIntStateOf(0) }
|
||||
var updateError by remember { mutableStateOf<String?>(null) }
|
||||
var downloadedApk by remember { mutableStateOf<java.io.File?>(null) }
|
||||
val updateScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
|
||||
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
|
||||
}
|
||||
|
||||
// Авторизация необязательна — всегда стартуем со станций.
|
||||
// Вход доступен из Настроек.
|
||||
val startDestination = NavDestinations.Stations.route
|
||||
@@ -75,9 +129,13 @@ class MainActivity : ComponentActivity() {
|
||||
.currentBackStackEntryAsState().value?.destination?.route
|
||||
val showChrome = currentRoute != NavDestinations.Auth.route
|
||||
|
||||
// Альбомная ориентация: вместо нижнего бара — боковой рейл слева,
|
||||
// мини-плеер уезжает под контент. Портрет — прежняя раскладка.
|
||||
val landscape = com.radiola.ui.util.isLandscape()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showChrome) {
|
||||
if (showChrome && !landscape) {
|
||||
Column(Modifier.navigationBarsPadding()) {
|
||||
if (currentStation != null) {
|
||||
MiniPlayer(
|
||||
@@ -96,10 +154,20 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
.then(if (landscape) Modifier.displayCutoutPadding() else Modifier)
|
||||
) {
|
||||
if (showChrome && landscape) {
|
||||
com.radiola.ui.navigation.SideNavRail(navController)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
enterTransition = {
|
||||
androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(220)) +
|
||||
androidx.compose.animation.slideInVertically(
|
||||
@@ -118,6 +186,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Charts.route) {
|
||||
ChartsScreen()
|
||||
}
|
||||
composable(NavDestinations.Favorites.route) {
|
||||
FavoritesScreen(
|
||||
onStationClick = { station ->
|
||||
@@ -136,9 +207,25 @@ class MainActivity : ComponentActivity() {
|
||||
SettingsScreen(
|
||||
onNavigateToAuth = {
|
||||
navController.navigate(NavDestinations.Auth.route)
|
||||
},
|
||||
onNavigateToAlarms = {
|
||||
navController.navigate(NavDestinations.Alarms.route)
|
||||
},
|
||||
onNavigateToEqualizer = {
|
||||
navController.navigate(NavDestinations.Equalizer.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Alarms.route) {
|
||||
AlarmsScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Equalizer.route) {
|
||||
com.radiola.ui.equalizer.EqualizerScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(NavDestinations.Auth.route) {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
@@ -154,6 +241,18 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showChrome && landscape && currentStation != null) {
|
||||
MiniPlayer(
|
||||
stationName = currentStation!!.name,
|
||||
track = currentTrack,
|
||||
isPlaying = isPlaying,
|
||||
onClick = { showPlayer = true },
|
||||
onPlayPause = { playerViewModel.togglePlayPause() }
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
} // конец контентной колонки
|
||||
} // конец Row (рейл + контент)
|
||||
}
|
||||
|
||||
if (showPlayer) {
|
||||
@@ -176,7 +275,98 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingUpdate?.let { update ->
|
||||
com.radiola.ui.update.UpdateDialog(
|
||||
versionName = update.versionName,
|
||||
notes = update.notes,
|
||||
isForce = update.forceUpdate,
|
||||
state = updateState,
|
||||
progress = updateProgress,
|
||||
errorMsg = updateError,
|
||||
onPrimary = {
|
||||
when (updateState) {
|
||||
com.radiola.ui.update.UpdateState.DOWNLOADED ->
|
||||
downloadedApk?.let {
|
||||
com.radiola.update.UpdateManager.installApk(this@MainActivity, it)
|
||||
}
|
||||
com.radiola.ui.update.UpdateState.DOWNLOADING -> {}
|
||||
else -> {
|
||||
updateState = com.radiola.ui.update.UpdateState.DOWNLOADING
|
||||
updateProgress = 0
|
||||
updateError = null
|
||||
updateScope.launch {
|
||||
val f = com.radiola.update.UpdateManager.downloadApk(
|
||||
this@MainActivity, update.downloadUrl
|
||||
) { updateProgress = it }
|
||||
if (f != null &&
|
||||
com.radiola.update.UpdateManager.verifySha256(f, update.sha256)
|
||||
) {
|
||||
downloadedApk = f
|
||||
updateState = com.radiola.ui.update.UpdateState.DOWNLOADED
|
||||
com.radiola.update.UpdateManager.installApk(this@MainActivity, f)
|
||||
} else {
|
||||
updateError = if (f == null) "Не удалось загрузить обновление"
|
||||
else "Контрольная сумма не совпала"
|
||||
updateState = com.radiola.ui.update.UpdateState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { if (!update.forceUpdate) pendingUpdate = null }
|
||||
)
|
||||
}
|
||||
|
||||
} // конец if (contentReady): тяжёлый контент композится под сплэшем
|
||||
|
||||
// Тематический экран загрузки поверх всего (рисуем сами — системный
|
||||
// сплэш Android 12+ нельзя перекрасить под выбранную тему).
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = showSplash,
|
||||
enter = androidx.compose.animation.EnterTransition.None,
|
||||
exit = androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(450))
|
||||
) {
|
||||
com.radiola.ui.components.SplashOverlay(
|
||||
com.radiola.ui.theme.ThemePalette.fromId(paletteId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Условия для стабильного фонового воспроизведения:
|
||||
* 1) POST_NOTIFICATIONS (Android 13+) — без него не видно медиа-уведомление и
|
||||
* foreground-сервис легко убивается системой;
|
||||
* 2) исключение из оптимизации батареи (Doze/ColorOS глушат фон).
|
||||
*/
|
||||
private fun ensureBackgroundPlaybackAllowed() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33 &&
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// После ответа (в колбэке) попросим про батарею — не два диалога разом.
|
||||
notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
maybeRequestBatteryExemption()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRequestBatteryExemption() {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
|
||||
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||
// Спрашиваем один раз на установку, чтобы не надоедать.
|
||||
val prefs = getSharedPreferences("radiola_prefs", MODE_PRIVATE)
|
||||
if (prefs.getBoolean("battery_opt_asked", false)) return
|
||||
prefs.edit().putBoolean("battery_opt_asked", true).apply()
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(
|
||||
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
android.net.Uri.parse("package:$packageName")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
package com.radiola
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class RadiolaApplication : Application()
|
||||
class RadiolaApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
// Явная настройка кэша Coil: иначе обложки (iTunes/Record/own-CDN) перекачиваются
|
||||
// каждую сессию, и нет контроля над памятью. Память 25% + диск 100МБ.
|
||||
override fun newImageLoader(): ImageLoader =
|
||||
ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(100L * 1024 * 1024)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.dao.RecognizedTrackDao
|
||||
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.AlarmEntity
|
||||
import com.radiola.data.local.entity.RecognizedTrackEntity
|
||||
import com.radiola.data.local.entity.RecordingEntity
|
||||
import com.radiola.data.local.entity.StationEntity
|
||||
import com.radiola.data.local.entity.TagEntity
|
||||
@@ -44,13 +48,65 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE stations ADD COLUMN qualities TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE recordings ADD COLUMN markers TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем таблицу будильников
|
||||
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS alarms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
hour INTEGER NOT NULL,
|
||||
minute INTEGER NOT NULL,
|
||||
daysMask INTEGER NOT NULL,
|
||||
stationId INTEGER NOT NULL,
|
||||
stationName TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
fadeInSec INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем таблицу истории распознанных треков (Shazam)
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recognized_track (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
song TEXT NOT NULL,
|
||||
stationName TEXT NOT NULL,
|
||||
coverUrl TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||
version = 4
|
||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class, RecognizedTrackEntity::class],
|
||||
version = 8
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun stationDao(): StationDao
|
||||
abstract fun trackHistoryDao(): TrackHistoryDao
|
||||
abstract fun tagDao(): TagDao
|
||||
abstract fun recordingDao(): RecordingDao
|
||||
abstract fun alarmDao(): AlarmDao
|
||||
abstract fun recognizedTrackDao(): RecognizedTrackDao
|
||||
}
|
||||
|
||||
@@ -29,16 +29,22 @@ class LocalStationDataSource @Inject constructor(
|
||||
.map { dto ->
|
||||
val group = groupMap[dto.groupId]
|
||||
val prefix = generatePrefix(dto.name)
|
||||
// Определяем сеть: только станции Radio Record можно обогащать
|
||||
// обложками из Record API. Остальные сети — свой источник.
|
||||
val isRecord = dto.site?.contains("radiorecord", ignoreCase = true) == true
|
||||
Station(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
prefix = prefix,
|
||||
streamUrl = dto.stream!!,
|
||||
coverUrl = group?.let { generateCoverUrl(it.name, dto.name) } ?: "",
|
||||
coverUrl = StationLogos.forName(dto.name) ?: StationLogos.forSite(dto.site) ?: "",
|
||||
genre = group?.name ?: "",
|
||||
tags = listOfNotNull(group?.name?.takeIf { it.isNotBlank() }),
|
||||
sortOrder = dto.id,
|
||||
source = "local"
|
||||
source = if (isRecord) "record" else "local",
|
||||
qualities = dto.qualities.orEmpty().map {
|
||||
com.radiola.domain.model.StreamQuality(it.bitrate, it.url, it.type)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
58
app/src/main/java/com/radiola/data/local/StationLogos.kt
Normal file
58
app/src/main/java/com/radiola/data/local/StationLogos.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.radiola.data.local
|
||||
|
||||
/**
|
||||
* Кураторские логотипы станций по домену сайта — для сетей, у которых нет
|
||||
* своего API обложек. Пополняется по мере проработки сетей.
|
||||
* Ключ — хост сайта (без www), значение — прямой URL квадратного логотипа.
|
||||
*/
|
||||
object StationLogos {
|
||||
|
||||
// Локальные обложки в ресурсах (res/drawable) — ключ: имя станции в нижнем регистре.
|
||||
private fun res(name: String) = "android.resource://com.radiola/drawable/$name"
|
||||
|
||||
// Логотипы каналов Love Radio (сгенерены из их SVG на фирменном цвете, захостены у нас)
|
||||
private fun love(id: Int) = "http://121.127.37.212:3000/covers/love_${id}_s.webp"
|
||||
|
||||
private val byName: Map<String, String> = mapOf(
|
||||
"comedy fm" to res("cover_comedy_fm"),
|
||||
"comedy spa" to res("cover_comedy_spa"),
|
||||
"standup" to res("cover_standup"),
|
||||
"женский standup" to res("cover_standup_women"),
|
||||
"love radio" to love(28),
|
||||
"love rnb" to love(2),
|
||||
"love top40" to love(3),
|
||||
"love dance" to love(4),
|
||||
"love chill" to love(5),
|
||||
"love gold" to love(6),
|
||||
"love russian" to love(7),
|
||||
"love kpop" to love(10),
|
||||
"love power" to love(11),
|
||||
"love summer" to love(1),
|
||||
)
|
||||
|
||||
private val byDomain: Map<String, String> = mapOf(
|
||||
// Comedy Radio (Comedy Club и пр.) — платформа 101.ru
|
||||
"comedy-radio.ru" to "https://comedy-radio.ru/design/images/logo/apple-touch-icon-180.png?v=2",
|
||||
// Like FM (GPM Radio)
|
||||
"likefm.ru" to "https://www.likefm.ru/apple-touch-icon.png",
|
||||
// Радио Романтика (GPM Radio)
|
||||
"radioromantika.ru" to "https://radioromantika.ru/design/images/new_romantika_images/img_for_design/icons/touch-icon-iphone-retina.png",
|
||||
)
|
||||
|
||||
/** Обложка по точному имени станции, либо null. */
|
||||
fun forName(name: String?): String? =
|
||||
name?.trim()?.lowercase()?.let { byName[it] }
|
||||
|
||||
/** Логотип станции по её сайту, либо null. */
|
||||
fun forSite(site: String?): String? {
|
||||
val host = site
|
||||
?.substringAfter("://", site)
|
||||
?.removePrefix("www.")
|
||||
?.substringBefore("/")
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
return byDomain[host]
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
Normal file
30
app/src/main/java/com/radiola/data/local/dao/AlarmDao.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
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.AlarmEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AlarmDao {
|
||||
|
||||
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
|
||||
fun getAll(): Flow<List<AlarmEntity>>
|
||||
|
||||
@Query("SELECT * FROM alarms ORDER BY hour ASC, minute ASC")
|
||||
suspend fun getAllOnce(): List<AlarmEntity>
|
||||
|
||||
@Query("SELECT * FROM alarms WHERE id = :id")
|
||||
suspend fun getById(id: Int): AlarmEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(alarm: AlarmEntity): Long
|
||||
|
||||
@Query("DELETE FROM alarms WHERE id = :id")
|
||||
suspend fun delete(id: Int)
|
||||
|
||||
@Query("UPDATE alarms SET enabled = :enabled WHERE id = :id")
|
||||
suspend fun setEnabled(id: Int, enabled: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.radiola.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import com.radiola.data.local.entity.RecognizedTrackEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RecognizedTrackDao {
|
||||
|
||||
@Query("SELECT * FROM recognized_track ORDER BY timestamp DESC LIMIT 200")
|
||||
fun getAll(): Flow<List<RecognizedTrackEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(track: RecognizedTrackEntity)
|
||||
|
||||
@Query("DELETE FROM recognized_track WHERE id NOT IN (SELECT id FROM recognized_track ORDER BY timestamp DESC LIMIT 200)")
|
||||
suspend fun cleanupOld()
|
||||
}
|
||||
@@ -21,6 +21,9 @@ interface RecordingDao {
|
||||
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
|
||||
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
|
||||
|
||||
@Query("UPDATE recordings SET markers = :markers WHERE id = :id")
|
||||
suspend fun updateMarkers(id: Long, markers: String)
|
||||
|
||||
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||
suspend fun getById(id: Long): RecordingEntity?
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ interface StationDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(stations: List<StationEntity>)
|
||||
|
||||
@Query("DELETE FROM stations WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Int>)
|
||||
|
||||
@Update
|
||||
suspend fun update(station: StationEntity)
|
||||
|
||||
@@ -34,4 +37,13 @@ interface StationDao {
|
||||
|
||||
@Query("SELECT id FROM stations WHERE isFavorite = 1")
|
||||
fun getFavoriteIds(): Flow<List<Int>>
|
||||
|
||||
// Разовое чтение id избранного — чтобы при пересоздании каталога (refreshStations)
|
||||
// не потерять отметки «избранное» (insertAll = REPLACE затирает строки).
|
||||
@Query("SELECT id FROM stations WHERE isFavorite = 1")
|
||||
suspend fun getFavoriteIdsOnce(): List<Int>
|
||||
|
||||
// Разовое чтение станции по id — используется в сервисе будильника.
|
||||
@Query("SELECT * FROM stations WHERE id = :id")
|
||||
suspend fun getByIdOnce(id: Int): StationEntity?
|
||||
}
|
||||
|
||||
@@ -17,7 +17,15 @@ data class LocalStationDto(
|
||||
@SerialName("bgColor") val bgColor: String? = null,
|
||||
@SerialName("enabled") val enabled: Boolean = true,
|
||||
@SerialName("notWorked") val notWorked: Boolean = false,
|
||||
@SerialName("isNew") val isNew: Boolean = false
|
||||
@SerialName("isNew") val isNew: Boolean = false,
|
||||
@SerialName("qualities") val qualities: List<LocalQualityDto>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalQualityDto(
|
||||
@SerialName("bitrate") val bitrate: Int,
|
||||
@SerialName("url") val url: String,
|
||||
@SerialName("type") val type: String = "aac"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Будильник: время, дни недели, станция, fade-in.
|
||||
* daysMask — битовая маска Пн..Вс (биты 0..6); 0 = разовый (следующее совпадение).
|
||||
*/
|
||||
@Entity(tableName = "alarms")
|
||||
data class AlarmEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val hour: Int,
|
||||
val minute: Int,
|
||||
val daysMask: Int, // 0 = разовый; бит 0=Пн, ..., бит 6=Вс
|
||||
val stationId: Int,
|
||||
val stationName: String,
|
||||
val enabled: Boolean = true,
|
||||
val fadeInSec: Int = 60
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.radiola.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "recognized_track")
|
||||
data class RecognizedTrackEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val stationName: String,
|
||||
val coverUrl: String?,
|
||||
val timestamp: Long
|
||||
)
|
||||
@@ -12,5 +12,7 @@ data class RecordingEntity(
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val trackName: String?,
|
||||
val duration: Long?
|
||||
val duration: Long?,
|
||||
// Тайм-коды треков: строки "offsetMs\tartist\tsong", разделённые \n.
|
||||
val markers: String = ""
|
||||
)
|
||||
|
||||
@@ -14,5 +14,7 @@ data class StationEntity(
|
||||
val tags: String,
|
||||
val sortOrder: Int,
|
||||
val source: String = "record",
|
||||
val isFavorite: Boolean = false
|
||||
val isFavorite: Boolean = false,
|
||||
// Качества потока, закодированы строкой: строки "bitrate\ttype\turl", разделённые \n.
|
||||
val qualities: String = ""
|
||||
)
|
||||
|
||||
@@ -9,7 +9,10 @@ object ApiMapper {
|
||||
|
||||
fun StationDto.toDomain(): Station {
|
||||
val cover = iconFillColored ?: bgImageMobile ?: bgImage ?: ""
|
||||
val stream = stream128 ?: stream320 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
|
||||
// ВНИМАНИЕ: поле stream_128 у части станций Record указывает на мёртвый
|
||||
// маунт {prefix}64.aacp (404) — звука нет, хотя обложка/трек есть. Поле
|
||||
// stream_320 (= {prefix}96.aacp) живо у ВСЕХ станций. Поэтому 320 первым.
|
||||
val stream = stream320 ?: stream128 ?: streamHls ?: "https://air.radiorecord.ru:805/${prefix}_128"
|
||||
return Station(
|
||||
id = id,
|
||||
name = name,
|
||||
@@ -17,7 +20,9 @@ object ApiMapper {
|
||||
streamUrl = stream,
|
||||
coverUrl = cover,
|
||||
genre = tooltip ?: "",
|
||||
tags = tags.map { it.name },
|
||||
// Жанры-категории станции лежат в "genre"; "tags" обычно пуст. Берём
|
||||
// оба, чтобы работали жанровые чипы Record (Лето и пр.).
|
||||
tags = (genres + tags).map { it.name }.distinct(),
|
||||
sortOrder = sort
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.SubmitCoverDto
|
||||
import com.radiola.domain.model.Track
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
|
||||
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
|
||||
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
|
||||
* Дальше обложка приходит ВСЕМ через /now-playing.
|
||||
*
|
||||
* Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается
|
||||
* первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не
|
||||
* злоупотреблять iTunes с устройства.
|
||||
*/
|
||||
@Singleton
|
||||
class CoverEnrichmentManager @Inject constructor(
|
||||
private val itunesApi: ItunesApi,
|
||||
private val radiolaApi: RadiolaApi,
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
// Что уже поставлено в общую очередь (чтобы не дублировать пачку now-playing).
|
||||
private val enqueued = Collections.synchronizedSet(HashSet<String>())
|
||||
// Что уже обработали (чтобы приоритет и общая дорожка не делали двойную работу).
|
||||
private val processed = Collections.synchronizedSet(HashSet<String>())
|
||||
|
||||
private val priority = Channel<Track>(Channel.UNLIMITED)
|
||||
private val normal = Channel<Track>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val track = priority.tryReceive().getOrNull() ?: select {
|
||||
priority.onReceive { it }
|
||||
normal.onReceive { it }
|
||||
}
|
||||
processOne(track)
|
||||
delay(THROTTLE_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Поставить пачку now-playing-треков без обложки в общую очередь. */
|
||||
fun enqueue(tracks: Collection<Track>) {
|
||||
for (t in tracks) {
|
||||
if (!isEnrichable(t)) continue
|
||||
if (enqueued.add(normKey(t))) normal.trySend(t)
|
||||
}
|
||||
}
|
||||
|
||||
/** Трек, который слушают прямо сейчас — вперёд очереди (вызывать при смене трека). */
|
||||
fun enqueuePriority(track: Track?) {
|
||||
if (track == null || !isEnrichable(track)) return
|
||||
priority.trySend(track)
|
||||
}
|
||||
|
||||
private fun isEnrichable(t: Track): Boolean =
|
||||
t.coverUrl.isNullOrBlank() && t.artist.isNotBlank() && t.song.isNotBlank()
|
||||
|
||||
private suspend fun processOne(track: Track) {
|
||||
val key = normKey(track)
|
||||
if (!processed.add(key)) return // уже обрабатывали (другая дорожка)
|
||||
try {
|
||||
val term = clean("${track.artist} ${track.song}")
|
||||
if (term.isBlank()) return
|
||||
val art = itunesApi.search(term).results.firstOrNull()?.artworkUrl100 ?: return
|
||||
// 100x100 → 600x600 (источник покрупнее, сервер всё равно ресайзит)
|
||||
val big = art.replace(Regex("/\\d+x\\d+bb\\."), "/600x600bb.")
|
||||
val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big))
|
||||
android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}")
|
||||
} catch (_: Exception) {
|
||||
// сеть/429/таймаут — не критично; снимаем метку, чтобы могли попробовать позже
|
||||
processed.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normKey(t: Track): String =
|
||||
"${t.artist.trim().lowercase()}|${t.song.trim().lowercase()}"
|
||||
|
||||
/** Убираем суффиксы «(Original Mix)», «[... Dub]» и пунктуацию — лучше матчит. */
|
||||
private fun clean(s: String): String = s
|
||||
.replace(Regex("\\([^)]*\\)|\\[[^\\]]*\\]"), " ")
|
||||
.replace(Regex("[^\\p{L}\\p{N}]+"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
companion object {
|
||||
private const val THROTTLE_MS = 800L
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/com/radiola/data/remote/ItunesApi.kt
Normal file
18
app/src/main/java/com/radiola/data/remote/ItunesApi.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.ItunesSearchResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* iTunes Search API — дёргаем С УСТРОЙСТВА пользователя (его IP не забанен
|
||||
* Apple, в отличие от нашего серверного). Нужна только обложка трека.
|
||||
*/
|
||||
interface ItunesApi {
|
||||
@GET("search")
|
||||
suspend fun search(
|
||||
@Query("term") term: String,
|
||||
@Query("entity") entity: String = "song",
|
||||
@Query("limit") limit: Int = 1,
|
||||
): ItunesSearchResponse
|
||||
}
|
||||
17
app/src/main/java/com/radiola/data/remote/LoveApi.kt
Normal file
17
app/src/main/java/com/radiola/data/remote/LoveApi.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.LoveConfigDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
|
||||
interface LoveApi {
|
||||
// Сессионный UID для доступа к потокам Love Radio (привязан к IP клиента,
|
||||
// поэтому запрашиваем именно с устройства).
|
||||
@GET("player/config")
|
||||
@Headers(
|
||||
"User-Agent: Mozilla/5.0",
|
||||
"Referer: https://www.loveradio.ru/",
|
||||
"Origin: https://www.loveradio.ru"
|
||||
)
|
||||
suspend fun getConfig(): LoveConfigDto
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Потоки Love Radio (n340.com) отдают музыку только с валидным сессионным UID,
|
||||
* привязанным к IP клиента. Берём UID с устройства из их player/config и
|
||||
* подставляем в URL потока. UID кэшируем (он стабилен в рамках сессии).
|
||||
*/
|
||||
@Singleton
|
||||
class LoveStreamResolver @Inject constructor(
|
||||
private val loveApi: LoveApi
|
||||
) {
|
||||
@Volatile
|
||||
private var cachedUid: String? = null
|
||||
|
||||
private fun isLove(url: String): Boolean =
|
||||
url.contains("n340.com") || url.contains("loveradio")
|
||||
|
||||
suspend fun resolve(url: String): String {
|
||||
if (!isLove(url)) return url
|
||||
val uid = cachedUid ?: runCatching { loveApi.getConfig().data.uid }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.also { cachedUid = it }
|
||||
if (uid.isNullOrBlank()) return url // фолбэк: пусть играет что есть
|
||||
val base = url.substringBefore("?")
|
||||
return "$base?type=aac&UID=$uid"
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/com/radiola/data/remote/LrcLibApi.kt
Normal file
24
app/src/main/java/com/radiola/data/remote/LrcLibApi.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.LrcLibLyricsDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface LrcLibApi {
|
||||
|
||||
@GET("api/get")
|
||||
suspend fun get(
|
||||
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
|
||||
@Query("artist_name") artistName: String,
|
||||
@Query("track_name") trackName: String,
|
||||
@Query("duration") durationSec: Int? = null
|
||||
): LrcLibLyricsDto
|
||||
|
||||
@GET("api/search")
|
||||
suspend fun search(
|
||||
@Header("User-Agent") userAgent: String = "radiOLA Android (https://radiorecord.ru)",
|
||||
@Query("artist_name") artistName: String,
|
||||
@Query("track_name") trackName: String
|
||||
): List<LrcLibLyricsDto>
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.radiola.data.remote
|
||||
|
||||
import com.radiola.data.remote.dto.AuthResponseDto
|
||||
import com.radiola.data.remote.dto.BackendNowPlayingDto
|
||||
import com.radiola.data.remote.dto.BackendStationDto
|
||||
import com.radiola.data.remote.dto.ChartsResponseDto
|
||||
import com.radiola.data.remote.dto.GenresResponseDto
|
||||
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.RecognizeResponseDto
|
||||
import com.radiola.data.remote.dto.TrackStatsDto
|
||||
import com.radiola.data.remote.dto.UserSettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
@@ -13,6 +18,7 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface RadiolaApi {
|
||||
|
||||
@@ -22,6 +28,21 @@ interface RadiolaApi {
|
||||
@POST("auth/verify")
|
||||
suspend fun verifyMagicLink(@Body dto: MagicLinkVerifyDto): AuthResponseDto
|
||||
|
||||
@GET("now-playing")
|
||||
suspend fun getNowPlaying(): List<BackendNowPlayingDto>
|
||||
|
||||
// Распознавание играющего трека через Shazam (бэкенд сам тянет аудио из потока).
|
||||
@POST("shazam/recognize/{stationId}")
|
||||
suspend fun recognizeTrack(@Path("stationId") stationId: Int): RecognizeResponseDto
|
||||
|
||||
// Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager).
|
||||
@POST("covers/submit")
|
||||
suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse
|
||||
|
||||
// station_id оффлайн-станций — скрываем их в каталоге (мёртвые потоки)
|
||||
@GET("stations/offline-ids")
|
||||
suspend fun getOfflineStationIds(): List<Int>
|
||||
|
||||
@GET("users/me")
|
||||
suspend fun getMe(): JsonObject
|
||||
|
||||
@@ -45,4 +66,25 @@ interface RadiolaApi {
|
||||
|
||||
@POST("users/me/history/{stationId}")
|
||||
suspend fun addHistory(@Path("stationId") stationId: String): JsonObject
|
||||
|
||||
// --- Чарты ---
|
||||
|
||||
@GET("charts/tracks")
|
||||
suspend fun getCharts(
|
||||
@Query("period") period: String,
|
||||
@Query("limit") limit: Int = 100,
|
||||
@Query("genre") genre: String? = null
|
||||
): ChartsResponseDto
|
||||
|
||||
@GET("charts/genres")
|
||||
suspend fun getGenres(): GenresResponseDto
|
||||
|
||||
@GET("charts/tracks/{trackId}")
|
||||
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto
|
||||
|
||||
@POST("charts/tracks/{trackId}/like")
|
||||
suspend fun likeTrack(@Path("trackId") trackId: String): JsonObject
|
||||
|
||||
@DELETE("charts/tracks/{trackId}/like")
|
||||
suspend fun unlikeTrack(@Path("trackId") trackId: String): JsonObject
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Текущий трек станции с нашего бэкенда (ключ — числовой id станции каталога).
|
||||
@Serializable
|
||||
data class BackendNowPlayingDto(
|
||||
@SerialName("stationId") val stationId: Int,
|
||||
@SerialName("name") val name: String = "",
|
||||
@SerialName("song") val song: String,
|
||||
@SerialName("artist") val artist: String,
|
||||
@SerialName("coverUrl") val coverUrl: String? = null
|
||||
)
|
||||
74
app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt
Normal file
74
app/src/main/java/com/radiola/data/remote/dto/ChartsDto.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** DTO ответа чартов — список позиций. */
|
||||
@Serializable
|
||||
data class ChartsResponseDto(
|
||||
val items: List<ChartEntryDto> = emptyList()
|
||||
)
|
||||
|
||||
/** DTO списка доступных жанров для фильтра. */
|
||||
@Serializable
|
||||
data class GenresResponseDto(
|
||||
val genres: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/** Одна позиция в чарте. */
|
||||
@Serializable
|
||||
data class ChartEntryDto(
|
||||
val rank: Int,
|
||||
val trackId: String,
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val coverUrl: String? = null,
|
||||
val genre: String? = null,
|
||||
val styles: List<String> = emptyList(),
|
||||
val label: String? = null,
|
||||
val year: Int? = null,
|
||||
val plays: Int = 0,
|
||||
val stationsCount: Int = 0,
|
||||
val likes: Int = 0,
|
||||
val prevRank: Int? = null,
|
||||
/** Направление: up | down | new | same */
|
||||
val trend: String = "same"
|
||||
)
|
||||
|
||||
/** Подробная статистика трека. */
|
||||
@Serializable
|
||||
data class TrackStatsDto(
|
||||
val trackId: String,
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val album: String? = null,
|
||||
val coverUrl: String? = null,
|
||||
val genre: String? = null,
|
||||
val styles: List<String> = emptyList(),
|
||||
val label: String? = null,
|
||||
val year: Int? = null,
|
||||
val releaseDate: String? = null,
|
||||
val firstSeen: String? = null,
|
||||
val totalPlays: Int = 0,
|
||||
val totalLikes: Int = 0,
|
||||
val isLiked: Boolean = false,
|
||||
val currentRank: Int? = null,
|
||||
val peakRank: Int? = null,
|
||||
val stations: List<StationPlaysDto> = emptyList(),
|
||||
val playsTimeline: List<PointDto> = emptyList(),
|
||||
val likesTimeline: List<PointDto> = emptyList()
|
||||
)
|
||||
|
||||
/** Проигрывания на конкретной станции. */
|
||||
@Serializable
|
||||
data class StationPlaysDto(
|
||||
val stationId: Int,
|
||||
val name: String,
|
||||
val plays: Int
|
||||
)
|
||||
|
||||
/** Одна точка тайм-лайна. */
|
||||
@Serializable
|
||||
data class PointDto(
|
||||
val date: String,
|
||||
val value: Int
|
||||
)
|
||||
28
app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt
Normal file
28
app/src/main/java/com/radiola/data/remote/dto/CoverDto.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Тело сабмита найденной клиентом обложки на наш бэкенд. */
|
||||
@Serializable
|
||||
data class SubmitCoverDto(
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val artworkUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubmitCoverResponse(
|
||||
val coverUrl: String? = null,
|
||||
)
|
||||
|
||||
/** Ответ iTunes Search API (берём только обложку). */
|
||||
@Serializable
|
||||
data class ItunesSearchResponse(
|
||||
val results: List<ItunesResult> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesResult(
|
||||
@SerialName("artworkUrl100") val artworkUrl100: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Ответ player/config Love Radio — нужен только uid сессии (для доступа к потоку)
|
||||
@Serializable
|
||||
data class LoveConfigDto(
|
||||
val data: LoveConfigData = LoveConfigData()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoveConfigData(
|
||||
val uid: String = ""
|
||||
)
|
||||
16
app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt
Normal file
16
app/src/main/java/com/radiola/data/remote/dto/LrcLibDto.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LrcLibLyricsDto(
|
||||
@SerialName("id") val id: Int? = null,
|
||||
@SerialName("trackName") val trackName: String? = null,
|
||||
@SerialName("artistName") val artistName: String? = null,
|
||||
@SerialName("albumName") val albumName: String? = null,
|
||||
@SerialName("duration") val duration: Double? = null,
|
||||
@SerialName("instrumental") val instrumental: Boolean = false,
|
||||
@SerialName("plainLyrics") val plainLyrics: String? = null,
|
||||
@SerialName("syncedLyrics") val syncedLyrics: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RecognizeResponseDto(
|
||||
val matched: Boolean,
|
||||
val artist: String? = null,
|
||||
val song: String? = null,
|
||||
val coverUrl: String? = null,
|
||||
val album: String? = null
|
||||
)
|
||||
@@ -16,6 +16,10 @@ data class StationDto(
|
||||
@SerialName("stream_128") val stream128: String? = null,
|
||||
@SerialName("stream_320") val stream320: String? = null,
|
||||
@SerialName("stream_hls") val streamHls: String? = null,
|
||||
// Record API кладёт жанры-категории станции в поле "genre" (массив {id,name}),
|
||||
// а не в "tags". Раньше читали только "tags" (его у станции нет) — поэтому
|
||||
// жанровые чипы Record (Лето, House, …) были пустыми. Берём оба.
|
||||
@SerialName("genre") val genres: List<TagDto> = emptyList(),
|
||||
@SerialName("tags") val tags: List<TagDto> = emptyList()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.data.remote.dto.ChartEntryDto
|
||||
import com.radiola.data.remote.dto.PointDto
|
||||
import com.radiola.data.remote.dto.StationPlaysDto
|
||||
import com.radiola.data.remote.dto.TrackStatsDto
|
||||
import com.radiola.domain.model.ChartEntry
|
||||
import com.radiola.domain.model.ChartPeriod
|
||||
import com.radiola.domain.model.ChartTrend
|
||||
import com.radiola.domain.model.StatPoint
|
||||
import com.radiola.domain.model.StationPlays
|
||||
import com.radiola.domain.model.TrackStats
|
||||
import com.radiola.domain.repository.ChartsRepository
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChartsRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi
|
||||
) : ChartsRepository {
|
||||
|
||||
override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
|
||||
return try {
|
||||
val response = api.getCharts(period.apiValue, genre = genre)
|
||||
response.items.map { it.toDomain() }
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getGenres(): List<String> {
|
||||
return try {
|
||||
api.getGenres().genres
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTrackStats(trackId: String): TrackStats {
|
||||
return api.getTrackStats(trackId).toDomain()
|
||||
}
|
||||
|
||||
override suspend fun setLiked(trackId: String, liked: Boolean) {
|
||||
try {
|
||||
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChartsRepository", "Ошибка лайка трека $trackId: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Маппинг DTO → Domain ----
|
||||
|
||||
private fun ChartEntryDto.toDomain() = ChartEntry(
|
||||
rank = rank,
|
||||
trackId = trackId,
|
||||
artist = artist,
|
||||
song = song,
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
styles = styles,
|
||||
label = label,
|
||||
year = year,
|
||||
plays = plays,
|
||||
stationsCount = stationsCount,
|
||||
likes = likes,
|
||||
prevRank = prevRank,
|
||||
trend = when (trend) {
|
||||
"up" -> ChartTrend.UP
|
||||
"down" -> ChartTrend.DOWN
|
||||
"new" -> ChartTrend.NEW
|
||||
else -> ChartTrend.SAME
|
||||
}
|
||||
)
|
||||
|
||||
private fun TrackStatsDto.toDomain() = TrackStats(
|
||||
trackId = trackId,
|
||||
artist = artist,
|
||||
song = song,
|
||||
album = album,
|
||||
coverUrl = coverUrl,
|
||||
genre = genre,
|
||||
styles = styles,
|
||||
label = label,
|
||||
year = year,
|
||||
releaseDate = releaseDate,
|
||||
firstSeen = firstSeen,
|
||||
totalPlays = totalPlays,
|
||||
totalLikes = totalLikes,
|
||||
isLiked = isLiked,
|
||||
currentRank = currentRank,
|
||||
peakRank = peakRank,
|
||||
stations = stations.map { it.toDomain() },
|
||||
playsTimeline = playsTimeline.map { it.toDomain() },
|
||||
likesTimeline = likesTimeline.map { it.toDomain() }
|
||||
)
|
||||
|
||||
private fun StationPlaysDto.toDomain() = StationPlays(stationId, name, plays)
|
||||
|
||||
private fun PointDto.toDomain(): StatPoint {
|
||||
val epochMs = try {
|
||||
Instant.parse(date).toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
// Попробуем как yyyy-MM-dd
|
||||
try {
|
||||
LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
} catch (e2: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
return StatPoint(epochMs, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.remote.LrcLibApi
|
||||
import com.radiola.domain.repository.LyricsRepository
|
||||
import com.radiola.domain.repository.LyricsResult
|
||||
import java.net.URLEncoder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val USER_AGENT = "radiOLA Android (https://radiorecord.ru)"
|
||||
|
||||
@Singleton
|
||||
class LyricsRepositoryImpl @Inject constructor(
|
||||
private val api: LrcLibApi
|
||||
) : LyricsRepository {
|
||||
|
||||
override fun providerUrl(artist: String, song: String): String {
|
||||
val query = URLEncoder.encode("$artist $song текст песни", "UTF-8")
|
||||
return "https://yandex.ru/search/?text=$query"
|
||||
}
|
||||
|
||||
override suspend fun fetchLyrics(
|
||||
artist: String,
|
||||
song: String,
|
||||
durationSec: Int?
|
||||
): LyricsResult? {
|
||||
val cleanArtist = artist.trim()
|
||||
val cleanSong = song.trim()
|
||||
if (cleanArtist.isEmpty() || cleanSong.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
// Сначала точный запрос
|
||||
val dto = api.get(
|
||||
userAgent = USER_AGENT,
|
||||
artistName = cleanArtist,
|
||||
trackName = cleanSong,
|
||||
durationSec = durationSec
|
||||
)
|
||||
LyricsResult(
|
||||
plain = dto.plainLyrics?.takeIf { it.isNotBlank() },
|
||||
synced = dto.syncedLyrics?.takeIf { it.isNotBlank() },
|
||||
instrumental = dto.instrumental
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Фолбэк на поиск — берём первый результат с непустым текстом
|
||||
try {
|
||||
val results = api.search(
|
||||
userAgent = USER_AGENT,
|
||||
artistName = cleanArtist,
|
||||
trackName = cleanSong
|
||||
)
|
||||
val found = results.firstOrNull { !it.plainLyrics.isNullOrBlank() }
|
||||
?: results.firstOrNull { it.instrumental }
|
||||
found?.let {
|
||||
LyricsResult(
|
||||
plain = it.plainLyrics?.takeIf { p -> p.isNotBlank() },
|
||||
synced = it.syncedLyrics?.takeIf { s -> s.isNotBlank() },
|
||||
instrumental = it.instrumental
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.remote.CoverEnrichmentManager
|
||||
import com.radiola.data.remote.NowPlayingSocketClient
|
||||
import com.radiola.data.remote.RecordApi
|
||||
import com.radiola.data.remote.ApiMapper.toDomain
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -11,8 +11,9 @@ import kotlinx.coroutines.flow.combine
|
||||
import javax.inject.Inject
|
||||
|
||||
class NowPlayingRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi,
|
||||
private val socketClient: NowPlayingSocketClient
|
||||
private val radiolaApi: RadiolaApi,
|
||||
private val socketClient: NowPlayingSocketClient,
|
||||
private val coverEnrichment: CoverEnrichmentManager
|
||||
) : NowPlayingRepository {
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<Map<Int, Track>>(emptyMap())
|
||||
@@ -21,28 +22,42 @@ class NowPlayingRepositoryImpl @Inject constructor(
|
||||
socketClient.connect()
|
||||
}
|
||||
|
||||
// Объединяем два источника: сокет (реалтайм, приоритет) и REST-поллинг
|
||||
// (refreshNowPlaying). Раньше REST-данные писались в _nowPlaying, но никем
|
||||
// не читались — из-за этого трек и обложка не отображались.
|
||||
// Сокет (реалтайм, приоритет) + REST-поллинг с нашего бэкенда.
|
||||
// Оба источника ключуются по числовому id станции каталога (== station.id),
|
||||
// поэтому матчатся однозначно — без коллизий по одинаковым названиям станций.
|
||||
// REST поллится регулярно и всегда свежий; socket-значения НАКАПЛИВАЮТСЯ и
|
||||
// НЕ обновляются, если сокет отвалился — поэтому REST в приоритете, иначе
|
||||
// залипшее socket-значение навсегда затеняет свежий трек (обложка/название
|
||||
// переставали обновляться).
|
||||
override fun getNowPlaying(stationId: Int): Flow<Track?> {
|
||||
return combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
|
||||
socketMap[stationId] ?: restMap[stationId]
|
||||
restMap[stationId] ?: socketMap[stationId]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllNowPlaying(): Flow<Map<Int, Track>> =
|
||||
combine(socketClient.nowPlaying, _nowPlaying) { socketMap, restMap ->
|
||||
restMap + socketMap
|
||||
socketMap + restMap
|
||||
}
|
||||
|
||||
override suspend fun refreshNowPlaying(): Result<Unit> {
|
||||
return try {
|
||||
val response = api.getNowPlaying()
|
||||
val map = response.result.associate { it.id to it.toDomain() }
|
||||
_nowPlaying.value = map
|
||||
val list = radiolaApi.getNowPlaying()
|
||||
_nowPlaying.value = list.associate { dto ->
|
||||
dto.stationId to Track(
|
||||
artist = dto.artist,
|
||||
song = dto.song,
|
||||
coverUrl = dto.coverUrl,
|
||||
stationName = dto.name
|
||||
)
|
||||
}
|
||||
// Треки без обложки — обогащаем через iTunes с устройства (наш IP забанен).
|
||||
coverEnrichment.enqueue(_nowPlaying.value.values)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enrichCoverNow(track: Track) = coverEnrichment.enqueuePriority(track)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.local.AppDatabase
|
||||
import com.radiola.data.local.entity.RecognizedTrackEntity
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class RecognizedTrackRepositoryImpl @Inject constructor(
|
||||
private val db: AppDatabase
|
||||
) : RecognizedTrackRepository {
|
||||
|
||||
override fun getHistory(): Flow<List<Track>> =
|
||||
db.recognizedTrackDao().getAll().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override suspend fun addTrack(track: Track) {
|
||||
db.recognizedTrackDao().insert(
|
||||
RecognizedTrackEntity(
|
||||
artist = track.artist,
|
||||
song = track.song,
|
||||
stationName = track.stationName,
|
||||
coverUrl = track.coverUrl,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
db.recognizedTrackDao().cleanupOld()
|
||||
}
|
||||
|
||||
private fun RecognizedTrackEntity.toDomain(): Track = Track(
|
||||
artist = artist, song = song, coverUrl = coverUrl, stationName = stationName
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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.model.TrackMarker
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.service.RecordingService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -24,10 +27,12 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
class RecordingRepositoryImpl @Inject constructor(
|
||||
private val db: AppDatabase,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val nowPlayingRepository: NowPlayingRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : RecordingRepository {
|
||||
|
||||
@@ -35,6 +40,7 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||
|
||||
private var recordingJob: Job? = null
|
||||
private var markerJob: Job? = null
|
||||
private var currentCall: okhttp3.Call? = null
|
||||
private var currentRecordingId: Long? = null
|
||||
|
||||
@@ -53,7 +59,9 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "radiola_recordings")
|
||||
dir.mkdirs()
|
||||
|
||||
val isHls = station.streamUrl.contains(".m3u8", ignoreCase = true)
|
||||
val ext = when {
|
||||
isHls -> "ts"
|
||||
station.streamUrl.contains(".aac", ignoreCase = true) -> "aac"
|
||||
station.streamUrl.contains(".mp3", ignoreCase = true) -> "mp3"
|
||||
else -> "audio"
|
||||
@@ -74,6 +82,36 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
db.recordingDao().insert(entity)
|
||||
_isRecording.value = true
|
||||
|
||||
// Захват тайм-кодов треков: первый трек на 0, далее по смене now-playing.
|
||||
val markers = mutableListOf<TrackMarker>()
|
||||
track?.let {
|
||||
if (it.artist.isNotBlank() || it.song.isNotBlank()) {
|
||||
markers.add(TrackMarker(0L, it.artist, it.song))
|
||||
}
|
||||
}
|
||||
markerJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
if (markers.isNotEmpty()) {
|
||||
try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {}
|
||||
}
|
||||
// Свой поллинг now-playing — чтобы метки писались независимо от экрана радио
|
||||
launch {
|
||||
while (isActive) {
|
||||
try { nowPlayingRepository.refreshNowPlaying() } catch (_: Exception) {}
|
||||
delay(8_000) // чаще — точнее тайм-коды треков в записи
|
||||
}
|
||||
}
|
||||
nowPlayingRepository.getNowPlaying(station.id)
|
||||
.distinctUntilChangedBy { "${it?.artist}|${it?.song}" }
|
||||
.collect { t ->
|
||||
if (t == null || (t.artist.isBlank() && t.song.isBlank())) return@collect
|
||||
val last = markers.lastOrNull()
|
||||
if (last != null && last.artist == t.artist && last.song == t.song) return@collect
|
||||
val offset = (System.currentTimeMillis() - id).coerceAtLeast(0L)
|
||||
markers.add(TrackMarker(offset, t.artist, t.song))
|
||||
try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Start foreground service to keep process alive during recording
|
||||
val serviceIntent = Intent(context, RecordingService::class.java).apply {
|
||||
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
|
||||
@@ -83,31 +121,12 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
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
|
||||
if (isHls) {
|
||||
recordHls(station.streamUrl, output)
|
||||
} else {
|
||||
recordRaw(station.streamUrl, output)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -120,11 +139,101 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Запись сплошного потока (ICY/Icecast): просто пишем тело ответа в файл. */
|
||||
private suspend fun recordRaw(streamUrl: String, output: FileOutputStream) {
|
||||
val request = Request.Builder().url(streamUrl).build()
|
||||
val call = okHttpClient.newCall(request)
|
||||
currentCall = call
|
||||
val response = call.execute()
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("RecordingRepo", "HTTP error: ${response.code}")
|
||||
return
|
||||
}
|
||||
val input = response.body?.byteStream() ?: run {
|
||||
Log.e("RecordingRepo", "Empty response body")
|
||||
return
|
||||
}
|
||||
val buffer = ByteArray(8192)
|
||||
while (coroutineContext.isActive) {
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
input.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись HLS-станций (EMG: Европа Плюс, Ретро FM и др.). Поток — это m3u8-плейлист,
|
||||
* а не сплошной поток, поэтому скачиваем .ts-сегменты и склеиваем в файл
|
||||
* (валидный MPEG-TS, ExoPlayer его проигрывает и перематывает).
|
||||
*/
|
||||
private suspend fun recordHls(streamUrl: String, output: FileOutputStream) {
|
||||
// 1. Резолвим мастер-плейлист в медиа-плейлист (берём первый вариант — у EMG
|
||||
// он наибольшего битрейта).
|
||||
var mediaUrl = streamUrl
|
||||
val firstText = httpGetText(mediaUrl) ?: return
|
||||
if (firstText.contains("#EXT-X-STREAM-INF")) {
|
||||
val variant = firstText.lineSequence()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
|
||||
if (variant != null) mediaUrl = resolveUrl(mediaUrl, variant)
|
||||
}
|
||||
|
||||
// 2. Опрашиваем медиа-плейлист, дописываем новые сегменты.
|
||||
val downloaded = LinkedHashSet<String>()
|
||||
var firstPass = true
|
||||
while (coroutineContext.isActive) {
|
||||
val text = httpGetText(mediaUrl)
|
||||
if (text == null) { delay(2000); continue }
|
||||
var targetDur = 6
|
||||
val segments = mutableListOf<String>()
|
||||
for (raw in text.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.startsWith("#EXT-X-TARGETDURATION:") ->
|
||||
targetDur = line.substringAfter(":").toIntOrNull() ?: targetDur
|
||||
line.isNotEmpty() && !line.startsWith("#") ->
|
||||
segments.add(resolveUrl(mediaUrl, line))
|
||||
}
|
||||
}
|
||||
segments.forEachIndexed { i, segUrl ->
|
||||
if (!coroutineContext.isActive || downloaded.contains(segUrl)) return@forEachIndexed
|
||||
downloaded.add(segUrl)
|
||||
// На первом проходе пропускаем «прошлое» окно (пишем только хвост),
|
||||
// чтобы запись начиналась примерно с момента нажатия.
|
||||
if (firstPass && i < segments.size - 2) return@forEachIndexed
|
||||
httpGetBytes(segUrl)?.let { output.write(it); output.flush() }
|
||||
}
|
||||
firstPass = false
|
||||
if (downloaded.size > 500) {
|
||||
val keep = downloaded.toList().takeLast(200)
|
||||
downloaded.clear(); downloaded.addAll(keep)
|
||||
}
|
||||
delay((targetDur * 500L).coerceIn(2000L, 6000L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun httpGetText(url: String): String? = try {
|
||||
okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build())
|
||||
.execute().use { if (it.isSuccessful) it.body?.string() else null }
|
||||
} catch (e: Exception) { Log.w("RecordingRepo", "playlist fetch fail: ${e.message}"); null }
|
||||
|
||||
private fun httpGetBytes(url: String): ByteArray? = try {
|
||||
okHttpClient.newCall(Request.Builder().url(url).header("User-Agent", "radiOLA").build())
|
||||
.execute().use { if (it.isSuccessful) it.body?.bytes() else null }
|
||||
} catch (e: Exception) { Log.w("RecordingRepo", "segment fetch fail: ${e.message}"); null }
|
||||
|
||||
/** Разрешает относительный URL (вариант/сегмент) относительно базового плейлиста. */
|
||||
private fun resolveUrl(base: String, ref: String): String =
|
||||
try { java.net.URI(base).resolve(ref).toString() } catch (e: Exception) { ref }
|
||||
|
||||
override suspend fun stopRecording() {
|
||||
currentCall?.cancel()
|
||||
currentCall = null
|
||||
recordingJob?.cancelAndJoin()
|
||||
recordingJob = null
|
||||
markerJob?.cancel()
|
||||
markerJob = null
|
||||
_isRecording.value = false
|
||||
|
||||
// Stop foreground service
|
||||
@@ -158,6 +267,22 @@ class RecordingRepositoryImpl @Inject constructor(
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
trackName = trackName,
|
||||
duration = duration
|
||||
duration = duration,
|
||||
markers = decodeMarkers(markers)
|
||||
)
|
||||
|
||||
// Метки кодируем строкой "offsetMs\tartist\tsong" по строкам \n
|
||||
// (названия треков не содержат \t/\n).
|
||||
private fun encodeMarkers(list: List<TrackMarker>): String =
|
||||
list.joinToString("\n") { "${it.offsetMs}\t${it.artist}\t${it.song}" }
|
||||
|
||||
private fun decodeMarkers(raw: String): List<TrackMarker> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw.split("\n").mapNotNull { line ->
|
||||
val p = line.split("\t")
|
||||
if (p.size != 3) return@mapNotNull null
|
||||
val off = p[0].toLongOrNull() ?: return@mapNotNull null
|
||||
TrackMarker(off, p[1], p[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import com.radiola.domain.repository.RegionRepository
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Определяет страну пользователя по IP (для гео-фильтрации). Код кэшируется в
|
||||
* настройках, так что после первого успешного запроса работает мгновенно и
|
||||
* оффлайн. Источник — публичные гео-IP сервисы (без ключа).
|
||||
*/
|
||||
@Singleton
|
||||
class RegionRepositoryImpl @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : RegionRepository {
|
||||
|
||||
override fun countryCode(): Flow<String?> = settingsRepository.getCountryCode()
|
||||
|
||||
override suspend fun refresh() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val code = fetchCountry()
|
||||
Log.d("RegionRepo", "country=$code")
|
||||
if (code != null) settingsRepository.setCountryCode(code)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchCountry(): String? {
|
||||
// IP — приоритет (учитывает VPN: при VPN страна = выходного узла, и тогда
|
||||
// украинские потоки доступны → не скрываем).
|
||||
ipCountry()?.let { return it }
|
||||
// Фолбэк, если IP-сервис недоступен (напр. заблокирован): страна SIM/сети/локали.
|
||||
return deviceCountry()
|
||||
}
|
||||
|
||||
private fun ipCountry(): String? {
|
||||
// 1) api.country.is → {"ip":"..","country":"RU"}
|
||||
request("https://api.country.is/")?.let { body ->
|
||||
Regex("\"country\"\\s*:\\s*\"([A-Za-z]{2})\"").find(body)?.groupValues?.get(1)?.let {
|
||||
return it.uppercase()
|
||||
}
|
||||
}
|
||||
// 2) ipapi.co/country/ → "RU"
|
||||
request("https://ipapi.co/country/")?.trim()?.let { body ->
|
||||
if (body.matches(Regex("[A-Za-z]{2}"))) return body.uppercase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun deviceCountry(): String? = try {
|
||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||
val net = tm?.networkCountryIso?.takeIf { it.isNotBlank() }
|
||||
val sim = tm?.simCountryIso?.takeIf { it.isNotBlank() }
|
||||
(net ?: sim ?: Locale.getDefault().country.takeIf { it.isNotBlank() })?.uppercase()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun request(url: String): String? = try {
|
||||
okHttpClient.newCall(
|
||||
Request.Builder().url(url).header("User-Agent", "radiOLA").build()
|
||||
).execute().use { if (it.isSuccessful) it.body?.string() else null }
|
||||
} catch (e: Exception) {
|
||||
Log.w("RegionRepo", "geo fetch fail ($url): ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
private val ENABLED_SERVICES = stringSetPreferencesKey("enabled_deeplink_services")
|
||||
private val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset")
|
||||
private val RECORDING_ENABLED = booleanPreferencesKey("recording_enabled")
|
||||
private val PREFERRED_BITRATE = intPreferencesKey("preferred_bitrate")
|
||||
private val COUNTRY_CODE = stringPreferencesKey("country_code")
|
||||
private val VISUALIZER_STYLE = stringPreferencesKey("visualizer_style")
|
||||
private val THEME_PALETTE = stringPreferencesKey("theme_palette")
|
||||
private val EQ_ENABLED = booleanPreferencesKey("eq_enabled")
|
||||
private val EQ_PRESET = intPreferencesKey("eq_preset")
|
||||
private val EQ_BANDS = stringPreferencesKey("eq_bands")
|
||||
private val EQ_BASS = intPreferencesKey("eq_bass")
|
||||
private val EQ_VIRTUALIZER = intPreferencesKey("eq_virtualizer")
|
||||
private val EQ_LOUDNESS = intPreferencesKey("eq_loudness")
|
||||
}
|
||||
|
||||
override fun getLastStationId(): Flow<Int?> = dataStore.data.map { it[LAST_STATION_ID] }
|
||||
@@ -49,4 +59,34 @@ class SettingsRepositoryImpl @Inject constructor(
|
||||
|
||||
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 fun getPreferredBitrate(): Flow<Int> = dataStore.data.map { it[PREFERRED_BITRATE] ?: 0 }
|
||||
override suspend fun setPreferredBitrate(bitrate: Int) { dataStore.edit { it[PREFERRED_BITRATE] = bitrate } }
|
||||
|
||||
override fun getCountryCode(): Flow<String?> = dataStore.data.map { it[COUNTRY_CODE] }
|
||||
override suspend fun setCountryCode(code: String) { dataStore.edit { it[COUNTRY_CODE] = code } }
|
||||
|
||||
override fun getVisualizerStyle(): Flow<String> = dataStore.data.map { it[VISUALIZER_STYLE] ?: "bars_center" }
|
||||
override suspend fun setVisualizerStyle(style: String) { dataStore.edit { it[VISUALIZER_STYLE] = style } }
|
||||
|
||||
override fun getThemePalette(): Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "forest" }
|
||||
override suspend fun setThemePalette(id: String) { dataStore.edit { it[THEME_PALETTE] = id } }
|
||||
|
||||
override fun getEqEnabled(): Flow<Boolean> = dataStore.data.map { it[EQ_ENABLED] ?: false }
|
||||
override suspend fun setEqEnabled(enabled: Boolean) { dataStore.edit { it[EQ_ENABLED] = enabled } }
|
||||
|
||||
override fun getEqPreset(): Flow<Int> = dataStore.data.map { it[EQ_PRESET] ?: -1 }
|
||||
override suspend fun setEqPreset(index: Int) { dataStore.edit { it[EQ_PRESET] = index } }
|
||||
|
||||
override fun getEqBands(): Flow<String> = dataStore.data.map { it[EQ_BANDS] ?: "" }
|
||||
override suspend fun setEqBands(csv: String) { dataStore.edit { it[EQ_BANDS] = csv } }
|
||||
|
||||
override fun getEqBass(): Flow<Int> = dataStore.data.map { it[EQ_BASS] ?: 0 }
|
||||
override suspend fun setEqBass(value: Int) { dataStore.edit { it[EQ_BASS] = value } }
|
||||
|
||||
override fun getEqVirtualizer(): Flow<Int> = dataStore.data.map { it[EQ_VIRTUALIZER] ?: 0 }
|
||||
override suspend fun setEqVirtualizer(value: Int) { dataStore.edit { it[EQ_VIRTUALIZER] = value } }
|
||||
|
||||
override fun getEqLoudness(): Flow<Int> = dataStore.data.map { it[EQ_LOUDNESS] ?: 0 }
|
||||
override suspend fun setEqLoudness(value: Int) { dataStore.edit { it[EQ_LOUDNESS] = value } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.radiola.data.repository
|
||||
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecognizeResult
|
||||
import com.radiola.domain.repository.ShazamRepository
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ShazamRepositoryImpl @Inject constructor(
|
||||
private val api: RadiolaApi
|
||||
) : ShazamRepository {
|
||||
|
||||
override suspend fun recognize(stationId: Int, stationName: String): RecognizeResult {
|
||||
return try {
|
||||
val res = api.recognizeTrack(stationId)
|
||||
if (res.matched && !res.artist.isNullOrBlank() && !res.song.isNullOrBlank()) {
|
||||
RecognizeResult.Found(
|
||||
Track(
|
||||
artist = res.artist,
|
||||
song = res.song,
|
||||
coverUrl = res.coverUrl,
|
||||
stationName = stationName
|
||||
)
|
||||
)
|
||||
} else {
|
||||
RecognizeResult.NotFound
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
val msg = when (e.code()) {
|
||||
503 -> "Распознавание временно недоступно"
|
||||
400 -> "На этой станции нет музыки"
|
||||
429 -> "Слишком много запросов, попробуйте позже"
|
||||
else -> "Не удалось распознать трек"
|
||||
}
|
||||
RecognizeResult.Error(msg)
|
||||
} catch (e: Exception) {
|
||||
RecognizeResult.Error("Нет связи с сервером")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,13 @@ 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.RadiolaApi
|
||||
import com.radiola.data.remote.ApiMapper.toDomain
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.StreamQuality
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -16,24 +20,29 @@ import javax.inject.Inject
|
||||
|
||||
class StationRepositoryImpl @Inject constructor(
|
||||
private val api: RecordApi,
|
||||
private val radiolaApi: RadiolaApi,
|
||||
private val db: AppDatabase,
|
||||
private val localDataSource: LocalStationDataSource
|
||||
) : StationRepository {
|
||||
|
||||
private val _tags = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
// Теги-чипы, которые не показываем (сезонные/мусорные). «Новый год» —
|
||||
// межсезонный тег Record (Christmas Chill и т.п. остаются в разделе Radio Record).
|
||||
private val hiddenTags = setOf("Новый год")
|
||||
|
||||
override fun getStations(): Flow<List<Station>> {
|
||||
return db.stationDao().getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshStations(): Result<Unit> {
|
||||
android.util.Log.d("StationRepo", "refreshStations() called")
|
||||
return try {
|
||||
override suspend fun refreshStations(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
// Тяжёлый парс stations.json (~700) + сетевые вызовы + запись в Room —
|
||||
// на IO, а не на главном потоке (был риск jank/ANR при холодном старте).
|
||||
try {
|
||||
// 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)
|
||||
@@ -41,9 +50,21 @@ class StationRepositoryImpl @Inject constructor(
|
||||
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
|
||||
// 3. Merge: local stations enriched with API data.
|
||||
// Локальные id (1,2,3…) не совпадают с id Record-каталога, и в ассетах
|
||||
// нет prefix — поэтому сопоставляем сначала по id, затем по названию
|
||||
// (стабильный общий ключ), иначе обложки/потоки не подтягиваются.
|
||||
// Индексы строим один раз (было O(n²): .find по ~700 на каждую из ~700).
|
||||
// asReversed+associateBy сохраняет ПЕРВЫЙ по имени (как делал .find).
|
||||
val apiById = apiStations.associateBy { it.id }
|
||||
val apiByName = apiStations.asReversed()
|
||||
.associateBy { it.name.trim().lowercase() }
|
||||
val merged = localStations.map { local ->
|
||||
val apiStation = apiStations.find { it.id == local.id }
|
||||
// Обложки/потоки из Record API — только для станций сети Radio Record.
|
||||
// Иначе чужим сетям (DFM, HitFM и т.д.) цеплялись бы обложки Record.
|
||||
val apiStation = if (local.source == "record") {
|
||||
apiById[local.id] ?: apiByName[local.name.trim().lowercase()]
|
||||
} else null
|
||||
if (apiStation != null) {
|
||||
val domain = apiStation.toDomain()
|
||||
local.copy(
|
||||
@@ -59,8 +80,9 @@ class StationRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB")
|
||||
// 4. Save to DB. Сохраняем текущие отметки «избранное», иначе REPLACE
|
||||
// в insertAll затрёт их при каждом пересоздании каталога (на старте).
|
||||
val favoriteIds = db.stationDao().getFavoriteIdsOnce().toSet()
|
||||
val entities = merged.mapIndexed { index, station ->
|
||||
StationEntity(
|
||||
id = station.id,
|
||||
@@ -72,15 +94,28 @@ class StationRepositoryImpl @Inject constructor(
|
||||
tags = station.tags.joinToString(","),
|
||||
sortOrder = index,
|
||||
source = station.source,
|
||||
isFavorite = false
|
||||
isFavorite = station.id in favoriteIds,
|
||||
qualities = encodeQualities(station.qualities)
|
||||
)
|
||||
}
|
||||
db.stationDao().insertAll(entities)
|
||||
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
|
||||
|
||||
// 4b. Скрываем станции, которые бэкенд пометил оффлайн (мёртвые потоки).
|
||||
// Если бэкенд недоступен — оставляем как есть (фолбэк на статичный enabled).
|
||||
try {
|
||||
val offlineIds = radiolaApi.getOfflineStationIds()
|
||||
if (offlineIds.isNotEmpty()) {
|
||||
db.stationDao().deleteByIds(offlineIds)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("StationRepo", "Не удалось получить offline-id: ${e.message}")
|
||||
}
|
||||
|
||||
// 5. Update tags: group names + API tags
|
||||
val groupNames = localGroups.map { it.name }.filter { it.isNotBlank() }
|
||||
val allTags = (groupNames + apiTags).distinct().sorted()
|
||||
val allTags = (groupNames + apiTags).distinct()
|
||||
.filterNot { it in hiddenTags }
|
||||
.sorted()
|
||||
db.tagDao().clearAll()
|
||||
db.tagDao().insertAll(allTags.map { TagEntity(it) })
|
||||
_tags.value = allTags
|
||||
@@ -109,6 +144,22 @@ class StationRepositoryImpl @Inject constructor(
|
||||
genre = genre,
|
||||
tags = tags.split(",").filter { it.isNotBlank() },
|
||||
sortOrder = sortOrder,
|
||||
source = source
|
||||
source = source,
|
||||
qualities = decodeQualities(qualities)
|
||||
)
|
||||
|
||||
// Качества кодируем строкой "bitrate\ttype\turl" по строкам (URL может содержать ; и |,
|
||||
// но не \t/\n — поэтому такие разделители безопасны).
|
||||
private fun encodeQualities(list: List<StreamQuality>): String =
|
||||
list.joinToString("\n") { "${it.bitrate}\t${it.type}\t${it.url}" }
|
||||
|
||||
private fun decodeQualities(raw: String): List<StreamQuality> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw.split("\n").mapNotNull { line ->
|
||||
val parts = line.split("\t")
|
||||
if (parts.size != 3) return@mapNotNull null
|
||||
val br = parts[0].toIntOrNull() ?: return@mapNotNull null
|
||||
StreamQuality(br, parts[2], parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,39 @@ 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.local.MIGRATION_4_5
|
||||
import com.radiola.data.local.MIGRATION_5_6
|
||||
import com.radiola.data.local.MIGRATION_6_7
|
||||
import com.radiola.data.local.MIGRATION_7_8
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.remote.AuthInterceptor
|
||||
import com.radiola.data.remote.LrcLibApi
|
||||
import com.radiola.data.remote.LoveApi
|
||||
import com.radiola.data.remote.RecordApi
|
||||
import com.radiola.data.remote.RadiolaApi
|
||||
import com.radiola.data.repository.AuthRepositoryImpl
|
||||
import com.radiola.data.repository.ChartsRepositoryImpl
|
||||
import com.radiola.data.repository.FavoritesRepositoryImpl
|
||||
import com.radiola.data.repository.LyricsRepositoryImpl
|
||||
import com.radiola.data.repository.NowPlayingRepositoryImpl
|
||||
import com.radiola.data.repository.RecordingRepositoryImpl
|
||||
import com.radiola.data.repository.RegionRepositoryImpl
|
||||
import com.radiola.data.repository.SettingsRepositoryImpl
|
||||
import com.radiola.data.repository.StationRepositoryImpl
|
||||
import com.radiola.data.repository.SyncRepositoryImpl
|
||||
import com.radiola.data.repository.RecognizedTrackRepositoryImpl
|
||||
import com.radiola.data.repository.ShazamRepositoryImpl
|
||||
import com.radiola.data.repository.TrackHistoryRepositoryImpl
|
||||
import com.radiola.domain.repository.AuthRepository
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import com.radiola.domain.repository.ShazamRepository
|
||||
import com.radiola.domain.repository.ChartsRepository
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.LyricsRepository
|
||||
import com.radiola.domain.repository.SyncRepository
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.RecordingRepository
|
||||
import com.radiola.domain.repository.RegionRepository
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
@@ -56,9 +73,15 @@ object AppModule {
|
||||
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
|
||||
})
|
||||
.apply {
|
||||
// Логирование каждого HTTP-запроса — только в debug. В релизе это лишний
|
||||
// оверхед на каждый вызов и утечка URL/деталей в logcat.
|
||||
if (com.radiola.BuildConfig.DEBUG) {
|
||||
addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
})
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@@ -69,6 +92,19 @@ object AppModule {
|
||||
authInterceptor: AuthInterceptor
|
||||
): OkHttpClient = baseClient.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
// Распознавание Shazam на бэкенде асинхронное (тянет аудио из потока +
|
||||
// поллит результат ~до 18с) — базового readTimeout 10с не хватает.
|
||||
// Поднимаем таймаут точечно только для этого пути.
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
if (req.url.encodedPath.contains("shazam/recognize")) {
|
||||
chain.withReadTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.withConnectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.proceed(req)
|
||||
} else {
|
||||
chain.proceed(req)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@@ -92,6 +128,46 @@ object AppModule {
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("itunes")
|
||||
fun provideItunesRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://itunes.apple.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideItunesApi(@Named("itunes") retrofit: Retrofit): com.radiola.data.remote.ItunesApi =
|
||||
retrofit.create(com.radiola.data.remote.ItunesApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("lrclib")
|
||||
fun provideLrcLibRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://lrclib.net/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named("love")
|
||||
fun provideLoveRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://api.loveradio.ru/api/v1/love-radio/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoveApi(@Named("love") retrofit: Retrofit): LoveApi = retrofit.create(LoveApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLrcLibApi(@Named("lrclib") retrofit: Retrofit): LrcLibApi = retrofit.create(LrcLibApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecordApi(@Named("record") retrofit: Retrofit): RecordApi = retrofit.create(RecordApi::class.java)
|
||||
@@ -104,9 +180,17 @@ object AppModule {
|
||||
@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)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAlarmDao(db: AppDatabase): AlarmDao = db.alarmDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideStationDao(db: AppDatabase): com.radiola.data.local.dao.StationDao = db.stationDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLocalStationDataSource(
|
||||
@@ -134,6 +218,10 @@ object AppModule {
|
||||
@Singleton
|
||||
fun provideSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRegionRepository(impl: RegionRepositoryImpl): RegionRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository = impl
|
||||
@@ -145,4 +233,20 @@ object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSyncRepository(impl: SyncRepositoryImpl): SyncRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChartsRepository(impl: ChartsRepositoryImpl): ChartsRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecognizedTrackRepository(impl: RecognizedTrackRepositoryImpl): RecognizedTrackRepository = impl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShazamRepository(impl: ShazamRepositoryImpl): ShazamRepository = impl
|
||||
}
|
||||
|
||||
26
app/src/main/java/com/radiola/domain/geo/GeoBlock.kt
Normal file
26
app/src/main/java/com/radiola/domain/geo/GeoBlock.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.radiola.domain.geo
|
||||
|
||||
import com.radiola.domain.model.Station
|
||||
|
||||
/**
|
||||
* Гео-ограничения станций. Украинские потоки (TavR Media: Radio ROKS, Kiss FM)
|
||||
* не отдаются на российские IP (geoblock) — для пользователей из РФ их полностью
|
||||
* скрываем (и сами станции, и их чипы-категории). При VPN (не-RU IP) — показываем,
|
||||
* т.к. потоки тогда доступны.
|
||||
*/
|
||||
object GeoBlock {
|
||||
// Хосты украинских станций. id 741 «Радио РОКС» (stream.roks.com) — российская,
|
||||
// под это правило НЕ попадает.
|
||||
private val UA_HOSTS = listOf("radioroks.ua", "kissfm.ua")
|
||||
private val BLOCKED_COUNTRIES = setOf("RU")
|
||||
|
||||
fun isUaStation(station: Station): Boolean =
|
||||
UA_HOSTS.any { station.streamUrl.contains(it, ignoreCase = true) }
|
||||
|
||||
/** Скрыта ли станция для пользователя из данной страны. */
|
||||
fun isHidden(station: Station, countryCode: String?): Boolean =
|
||||
countryCode in BLOCKED_COUNTRIES && isUaStation(station)
|
||||
|
||||
/** Нужно ли вообще скрывать украинские станции в этой стране. */
|
||||
fun shouldHideUa(countryCode: String?): Boolean = countryCode in BLOCKED_COUNTRIES
|
||||
}
|
||||
30
app/src/main/java/com/radiola/domain/model/ChartEntry.kt
Normal file
30
app/src/main/java/com/radiola/domain/model/ChartEntry.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
/** Период чарта, выбираемый пользователем. */
|
||||
enum class ChartPeriod(val apiValue: String, val label: String) {
|
||||
DAY("day", "День"),
|
||||
WEEK("week", "Неделя"),
|
||||
MONTH("month", "Месяц"),
|
||||
ALL("all", "Всё время")
|
||||
}
|
||||
|
||||
/** Направление движения позиции в чарте. */
|
||||
enum class ChartTrend { UP, DOWN, NEW, SAME }
|
||||
|
||||
/** Одна позиция в чарте. */
|
||||
data class ChartEntry(
|
||||
val rank: Int,
|
||||
val trackId: String,
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val coverUrl: String?,
|
||||
val genre: String?,
|
||||
val styles: List<String>,
|
||||
val label: String?,
|
||||
val year: Int?,
|
||||
val plays: Int,
|
||||
val stationsCount: Int,
|
||||
val likes: Int,
|
||||
val prevRank: Int?,
|
||||
val trend: ChartTrend
|
||||
)
|
||||
18
app/src/main/java/com/radiola/domain/model/MusicGenres.kt
Normal file
18
app/src/main/java/com/radiola/domain/model/MusicGenres.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
/**
|
||||
* Клиентский признак «музыкальная ли станция». На разговорных/юмористических/
|
||||
* новостных станциях распознавать нечего — кнопку Shazam там не показываем.
|
||||
* Список синхронизирован с backend (common/station-classification.ts).
|
||||
*/
|
||||
object MusicGenres {
|
||||
private val NON_MUSIC = setOf(
|
||||
"Станция Кассиопея", "Юмор ФМ", "Рассказы", "Радио Вера",
|
||||
"Comedy Radio", "ВГТРК", "Старое радио",
|
||||
)
|
||||
|
||||
fun isMusicStation(genre: String?): Boolean {
|
||||
if (genre.isNullOrBlank()) return true
|
||||
return genre.trim() !in NON_MUSIC
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,17 @@ data class Recording(
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val trackName: String?,
|
||||
val duration: Long?
|
||||
val duration: Long?,
|
||||
// Тайм-коды треков, звучавших во время записи (для навигации при прослушивании).
|
||||
val markers: List<TrackMarker> = emptyList()
|
||||
)
|
||||
|
||||
/** Отметка трека в записи: смещение от начала записи + что играло. */
|
||||
data class TrackMarker(
|
||||
val offsetMs: Long,
|
||||
val artist: String,
|
||||
val song: String
|
||||
) {
|
||||
val title: String
|
||||
get() = listOf(artist, song).filter { it.isNotBlank() }.joinToString(" — ")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
// @Immutable: модели read-only (заполняются один раз из БД/маппера и не мутируются).
|
||||
// Список tags/qualities иначе делает класс «нестабильным» для Compose → лишние
|
||||
// рекомпозиции списков станций. Помечаем явно — компилятор сможет пропускать.
|
||||
@Immutable
|
||||
data class Station(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@@ -9,5 +15,25 @@ data class Station(
|
||||
val genre: String,
|
||||
val tags: List<String>,
|
||||
val sortOrder: Int,
|
||||
val source: String = "record"
|
||||
val source: String = "record",
|
||||
// Доступные качества потока (битрейты). Пусто или один элемент — переключателя нет.
|
||||
val qualities: List<StreamQuality> = emptyList()
|
||||
)
|
||||
|
||||
/** Один вариант качества потока станции. */
|
||||
@Immutable
|
||||
data class StreamQuality(
|
||||
val bitrate: Int, // kbps
|
||||
val url: String,
|
||||
val type: String // "aac" | "mp3"
|
||||
) {
|
||||
/** Человекочитаемая ступень качества по битрейту. */
|
||||
val tierLabel: String
|
||||
get() = when {
|
||||
bitrate >= 256 -> "Максимальное"
|
||||
bitrate >= 128 -> "Высокое"
|
||||
bitrate >= 96 -> "Среднее"
|
||||
bitrate >= 64 -> "Экономно"
|
||||
else -> "Минимальное"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class Track(
|
||||
val artist: String,
|
||||
val song: String,
|
||||
|
||||
38
app/src/main/java/com/radiola/domain/model/TrackStats.kt
Normal file
38
app/src/main/java/com/radiola/domain/model/TrackStats.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.radiola.domain.model
|
||||
|
||||
/** Одна точка на графике популярности. */
|
||||
data class StatPoint(
|
||||
/** Метка времени в epoch-миллисекундах. */
|
||||
val date: Long,
|
||||
val value: Int
|
||||
)
|
||||
|
||||
/** Проигрывания на конкретной станции. */
|
||||
data class StationPlays(
|
||||
val stationId: Int,
|
||||
val name: String,
|
||||
val plays: Int
|
||||
)
|
||||
|
||||
/** Полная статистика трека (детальная карточка). */
|
||||
data class TrackStats(
|
||||
val trackId: String,
|
||||
val artist: String,
|
||||
val song: String,
|
||||
val album: String?,
|
||||
val coverUrl: String?,
|
||||
val genre: String?,
|
||||
val styles: List<String>,
|
||||
val label: String?,
|
||||
val year: Int?,
|
||||
val releaseDate: String?,
|
||||
val firstSeen: String?,
|
||||
val totalPlays: Int,
|
||||
val totalLikes: Int,
|
||||
val isLiked: Boolean,
|
||||
val currentRank: Int?,
|
||||
val peakRank: Int?,
|
||||
val stations: List<StationPlays>,
|
||||
val playsTimeline: List<StatPoint>,
|
||||
val likesTimeline: List<StatPoint>
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.ChartEntry
|
||||
import com.radiola.domain.model.ChartPeriod
|
||||
import com.radiola.domain.model.TrackStats
|
||||
|
||||
interface ChartsRepository {
|
||||
suspend fun getCharts(period: ChartPeriod, genre: String? = null): List<ChartEntry>
|
||||
suspend fun getGenres(): List<String>
|
||||
suspend fun getTrackStats(trackId: String): TrackStats
|
||||
suspend fun setLiked(trackId: String, liked: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
/**
|
||||
* Тексты песен предоставляются через публичный API LRCLIB (https://lrclib.net).
|
||||
* LRCLIB — открытая база текстов без авторских ограничений (CC0 / community-maintained).
|
||||
*/
|
||||
interface LyricsRepository {
|
||||
|
||||
/** URL поиска-фолбэк (Яндекс). */
|
||||
fun providerUrl(artist: String, song: String): String
|
||||
|
||||
/** Загрузить текст трека через LRCLIB. null — трек не найден. */
|
||||
suspend fun fetchLyrics(
|
||||
artist: String,
|
||||
song: String,
|
||||
durationSec: Int? = null
|
||||
): LyricsResult?
|
||||
}
|
||||
|
||||
data class LyricsResult(
|
||||
val plain: String?,
|
||||
val synced: String?,
|
||||
val instrumental: Boolean
|
||||
)
|
||||
@@ -7,4 +7,6 @@ interface NowPlayingRepository {
|
||||
fun getNowPlaying(stationId: Int): Flow<Track?>
|
||||
fun getAllNowPlaying(): Flow<Map<Int, Track>>
|
||||
suspend fun refreshNowPlaying(): Result<Unit>
|
||||
/** Обогатить обложку трека приоритетно (тот, что слушают прямо сейчас). */
|
||||
fun enrichCoverNow(track: Track)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.Track
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface RecognizedTrackRepository {
|
||||
fun getHistory(): Flow<List<Track>>
|
||||
suspend fun addTrack(track: Track)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Регион пользователя (по IP) — для гео-фильтрации станций. */
|
||||
interface RegionRepository {
|
||||
/** Код страны пользователя (напр. "RU"), null пока не определён. */
|
||||
fun countryCode(): Flow<String?>
|
||||
|
||||
/** Обновить код страны по IP (кэшируется). */
|
||||
suspend fun refresh()
|
||||
}
|
||||
@@ -14,4 +14,32 @@ interface SettingsRepository {
|
||||
suspend fun setEqualizerPreset(preset: String)
|
||||
fun isRecordingEnabled(): Flow<Boolean>
|
||||
suspend fun setRecordingEnabled(enabled: Boolean)
|
||||
// Предпочитаемый битрейт (kbps). 0 = авто (брать качество по умолчанию станции).
|
||||
fun getPreferredBitrate(): Flow<Int>
|
||||
suspend fun setPreferredBitrate(bitrate: Int)
|
||||
// Код страны пользователя (по IP), напр. "RU". null — не определён.
|
||||
fun getCountryCode(): Flow<String?>
|
||||
suspend fun setCountryCode(code: String)
|
||||
// Стиль визуализатора звука в плеере (ключ VisualizerStyle).
|
||||
fun getVisualizerStyle(): Flow<String>
|
||||
suspend fun setVisualizerStyle(style: String)
|
||||
// Цветовая тема приложения (id ThemePalette, напр. "forest"). По умолчанию "forest".
|
||||
fun getThemePalette(): Flow<String>
|
||||
suspend fun setThemePalette(id: String)
|
||||
|
||||
// ── Эквалайзер и улучшайзеры звука (android.media.audiofx) ──
|
||||
fun getEqEnabled(): Flow<Boolean>
|
||||
suspend fun setEqEnabled(enabled: Boolean)
|
||||
// Индекс системного пресета эквалайзера; -1 = свой (ручные полосы).
|
||||
fun getEqPreset(): Flow<Int>
|
||||
suspend fun setEqPreset(index: Int)
|
||||
// Уровни полос в миллибелах через запятую (под текущее число полос устройства).
|
||||
fun getEqBands(): Flow<String>
|
||||
suspend fun setEqBands(csv: String)
|
||||
fun getEqBass(): Flow<Int> // 0..100 %
|
||||
suspend fun setEqBass(value: Int)
|
||||
fun getEqVirtualizer(): Flow<Int> // 0..100 %
|
||||
suspend fun setEqVirtualizer(value: Int)
|
||||
fun getEqLoudness(): Flow<Int> // 0..100 % → 0..+12 дБ
|
||||
suspend fun setEqLoudness(value: Int)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.radiola.domain.repository
|
||||
|
||||
import com.radiola.domain.model.Track
|
||||
|
||||
sealed interface RecognizeResult {
|
||||
data class Found(val track: Track) : RecognizeResult
|
||||
data object NotFound : RecognizeResult
|
||||
data class Error(val message: String) : RecognizeResult
|
||||
}
|
||||
|
||||
interface ShazamRepository {
|
||||
suspend fun recognize(stationId: Int, stationName: String): RecognizeResult
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
package com.radiola.domain.usecase
|
||||
|
||||
import com.radiola.domain.geo.GeoBlock
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.repository.RegionRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetStationsUseCase @Inject constructor(
|
||||
private val stationRepository: StationRepository
|
||||
private val stationRepository: StationRepository,
|
||||
private val regionRepository: RegionRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<List<Station>> = stationRepository.getStations()
|
||||
// Гео-фильтр: для пользователей из РФ убираем недоступные украинские станции
|
||||
// (Radio ROKS, Kiss FM) из всех мест, где используется список станций.
|
||||
operator fun invoke(): Flow<List<Station>> = combine(
|
||||
stationRepository.getStations(),
|
||||
regionRepository.countryCode()
|
||||
) { stations, country ->
|
||||
stations.filterNot { GeoBlock.isHidden(it, country) }
|
||||
}
|
||||
}
|
||||
|
||||
23
app/src/main/java/com/radiola/service/AlarmReceiver.kt
Normal file
23
app/src/main/java/com/radiola/service/AlarmReceiver.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* BroadcastReceiver-триггер будильника.
|
||||
* Не Hilt-инжектируемый — намеренно простой: только передаёт id в PlayerService.
|
||||
*/
|
||||
class AlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val alarmId = intent.getIntExtra("alarm_id", -1)
|
||||
if (alarmId < 0) return
|
||||
|
||||
val serviceIntent = Intent(context, PlayerService::class.java).apply {
|
||||
action = PlayerService.ACTION_ALARM
|
||||
putExtra("alarm_id", alarmId)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/com/radiola/service/AlarmScheduler.kt
Normal file
147
app/src/main/java/com/radiola/service/AlarmScheduler.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.radiola.MainActivity
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
/**
|
||||
* Запланировать следующее срабатывание будильника.
|
||||
* Если daysMask == 0 — разовый: берём ближайшее сегодня/завтра совпадение по времени.
|
||||
* Если daysMask != 0 — повторяющийся: ищем ближайший день недели из маски.
|
||||
*/
|
||||
fun schedule(alarm: AlarmEntity) {
|
||||
val triggerMs = nextTriggerMillis(alarm)
|
||||
val operation = buildPendingIntent(alarm.id)
|
||||
val showIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAlarmClock(
|
||||
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
|
||||
operation
|
||||
)
|
||||
} else {
|
||||
// Нет разрешения на точные будильники — используем менее точный метод
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
|
||||
}
|
||||
} else {
|
||||
alarmManager.setAlarmClock(
|
||||
AlarmManager.AlarmClockInfo(triggerMs, showIntent),
|
||||
operation
|
||||
)
|
||||
}
|
||||
Log.d("AlarmScheduler", "Будильник #${alarm.id} запланирован на $triggerMs")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("AlarmScheduler", "SecurityException при setAlarmClock — фолбэк на setAndAllowWhileIdle", e)
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMs, operation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Отменить будильник по id. */
|
||||
fun cancel(alarmId: Int) {
|
||||
alarmManager.cancel(buildPendingIntent(alarmId))
|
||||
Log.d("AlarmScheduler", "Будильник #$alarmId отменён")
|
||||
}
|
||||
|
||||
/** Пересчитать расписание всех будильников из базы. */
|
||||
suspend fun rescheduleAll(alarmDao: AlarmDao) {
|
||||
val alarms = alarmDao.getAllOnce()
|
||||
alarms.forEach { alarm ->
|
||||
cancel(alarm.id)
|
||||
if (alarm.enabled) schedule(alarm)
|
||||
}
|
||||
Log.d("AlarmScheduler", "Перепланировано ${alarms.size} будильников")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private fun buildPendingIntent(alarmId: Int): PendingIntent {
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
putExtra("alarm_id", alarmId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
alarmId,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычислить эпоху (мс) следующего срабатывания будильника.
|
||||
* Calendar.MONDAY=2 .. Calendar.SUNDAY=1 — маппим в биты 0..6 (Пн..Вс).
|
||||
*/
|
||||
private fun nextTriggerMillis(alarm: AlarmEntity): Long {
|
||||
val cal = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, alarm.hour)
|
||||
set(Calendar.MINUTE, alarm.minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
return if (alarm.daysMask == 0) {
|
||||
// Разовый: если время уже прошло сегодня — сдвигаем на завтра
|
||||
if (cal.timeInMillis <= System.currentTimeMillis()) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
cal.timeInMillis
|
||||
} else {
|
||||
// Повторяющийся: ищем ближайший день из маски
|
||||
var found = false
|
||||
repeat(7) { offset ->
|
||||
if (!found) {
|
||||
val checkCal = cal.clone() as Calendar
|
||||
if (offset > 0) checkCal.add(Calendar.DAY_OF_YEAR, offset)
|
||||
val bit = calDayToBit(checkCal.get(Calendar.DAY_OF_WEEK))
|
||||
if (alarm.daysMask and (1 shl bit) != 0) {
|
||||
// Тот же день, но время уже прошло → пропускаем
|
||||
if (offset == 0 && checkCal.timeInMillis <= System.currentTimeMillis()) return@repeat
|
||||
cal.timeInMillis = checkCal.timeInMillis
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Крайний случай: все биты проверены — ждём ещё 7 дней (не должно случиться)
|
||||
cal.add(Calendar.DAY_OF_YEAR, 7)
|
||||
}
|
||||
cal.timeInMillis
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевод Calendar.DAY_OF_WEEK → бит в daysMask (0=Пн, 6=Вс).
|
||||
* Calendar: SUNDAY=1, MONDAY=2, ..., SATURDAY=7
|
||||
*/
|
||||
private fun calDayToBit(calDay: Int): Int = when (calDay) {
|
||||
Calendar.MONDAY -> 0
|
||||
Calendar.TUESDAY -> 1
|
||||
Calendar.WEDNESDAY -> 2
|
||||
Calendar.THURSDAY -> 3
|
||||
Calendar.FRIDAY -> 4
|
||||
Calendar.SATURDAY -> 5
|
||||
Calendar.SUNDAY -> 6
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
259
app/src/main/java/com/radiola/service/AudioEffectsController.kt
Normal file
259
app/src/main/java/com/radiola/service/AudioEffectsController.kt
Normal file
@@ -0,0 +1,259 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.Context
|
||||
import android.media.audiofx.BassBoost
|
||||
import android.media.audiofx.Equalizer
|
||||
import android.media.audiofx.LoudnessEnhancer
|
||||
import android.media.audiofx.Virtualizer
|
||||
import android.util.Log
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Одна полоса эквалайзера: центральная частота и текущий/предельный уровень в мБ. */
|
||||
data class EqBand(
|
||||
val index: Int,
|
||||
val centerHz: Int,
|
||||
val minMb: Int,
|
||||
val maxMb: Int,
|
||||
val levelMb: Int
|
||||
)
|
||||
|
||||
/** Снимок состояния эквалайзера и улучшайзеров для UI. */
|
||||
data class EqState(
|
||||
val available: Boolean = false,
|
||||
val enabled: Boolean = false,
|
||||
val bands: List<EqBand> = emptyList(),
|
||||
val presets: List<String> = emptyList(),
|
||||
val currentPreset: Int = -1, // -1 = свой
|
||||
val hasBass: Boolean = false,
|
||||
val hasVirtualizer: Boolean = false,
|
||||
val hasLoudness: Boolean = false,
|
||||
val bass: Int = 0, // 0..100 %
|
||||
val virtualizer: Int = 0, // 0..100 %
|
||||
val loudness: Int = 0 // 0..100 % → 0..+12 дБ
|
||||
)
|
||||
|
||||
/**
|
||||
* Управляет системными аудиоэффектами (android.media.audiofx), привязанными к
|
||||
* аудиосессии плеера: графический эквалайзер + Bass Boost + Virtualizer (объём) +
|
||||
* LoudnessEnhancer (громкость тихих). Применяет в реальном времени, переживает смену
|
||||
* станции (сессия фиксированная), сохраняет настройки в DataStore. Эффекты — best-effort:
|
||||
* на устройствах без поддержки соответствующий блок просто недоступен (null).
|
||||
*/
|
||||
@Singleton
|
||||
class AudioEffectsController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: SettingsRepository
|
||||
) {
|
||||
private val tag = "AudioEffects"
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
private var equalizer: Equalizer? = null
|
||||
private var bassBoost: BassBoost? = null
|
||||
private var virtualizer: Virtualizer? = null
|
||||
private var loudness: LoudnessEnhancer? = null
|
||||
private var masterEnabled = false
|
||||
|
||||
private val _state = MutableStateFlow(EqState())
|
||||
val state: StateFlow<EqState> = _state.asStateFlow()
|
||||
|
||||
/** Привязывает эффекты к аудиосессии и применяет сохранённые настройки. */
|
||||
fun attach(sessionId: Int) {
|
||||
release()
|
||||
equalizer = try {
|
||||
Equalizer(0, sessionId)
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Equalizer недоступен: ${e.message}"); null
|
||||
}
|
||||
bassBoost = try {
|
||||
BassBoost(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
virtualizer = try {
|
||||
Virtualizer(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
loudness = try {
|
||||
LoudnessEnhancer(sessionId)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
scope.launch { loadAndApply() }
|
||||
}
|
||||
|
||||
private suspend fun loadAndApply() {
|
||||
val enabled = settings.getEqEnabled().first()
|
||||
val preset = settings.getEqPreset().first()
|
||||
val bandsCsv = settings.getEqBands().first()
|
||||
val bass = settings.getEqBass().first()
|
||||
val virt = settings.getEqVirtualizer().first()
|
||||
val loud = settings.getEqLoudness().first()
|
||||
|
||||
masterEnabled = enabled
|
||||
val eq = equalizer
|
||||
if (eq != null) {
|
||||
runCatching { eq.enabled = enabled }
|
||||
val saved = bandsCsv.split(",").mapNotNull { it.trim().toShortOrNull() }
|
||||
if (preset in 0 until eq.numberOfPresets) {
|
||||
runCatching { eq.usePreset(preset.toShort()) }
|
||||
} else if (saved.size == eq.numberOfBands.toInt()) {
|
||||
saved.forEachIndexed { i, lvl -> runCatching { eq.setBandLevel(i.toShort(), lvl) } }
|
||||
}
|
||||
}
|
||||
applyBassInternal(bass)
|
||||
applyVirtualizerInternal(virt)
|
||||
applyLoudnessInternal(loud)
|
||||
emitState(enabled, preset, bass, virt, loud)
|
||||
}
|
||||
|
||||
// ── Публичные действия (UI) ──
|
||||
|
||||
fun setEnabled(on: Boolean) {
|
||||
masterEnabled = on
|
||||
runCatching { equalizer?.enabled = on }
|
||||
val s = _state.value
|
||||
applyBassInternal(s.bass)
|
||||
applyVirtualizerInternal(s.virtualizer)
|
||||
applyLoudnessInternal(s.loudness)
|
||||
persist { settings.setEqEnabled(on) }
|
||||
_state.value = s.copy(enabled = on)
|
||||
}
|
||||
|
||||
fun selectPreset(index: Int) {
|
||||
val eq = equalizer ?: return
|
||||
if (index !in 0 until eq.numberOfPresets) return
|
||||
runCatching { eq.usePreset(index.toShort()) }
|
||||
persist { settings.setEqPreset(index); settings.setEqBands(currentBandsCsv()) }
|
||||
emitState(_state.value.enabled, index, _state.value.bass, _state.value.virtualizer, _state.value.loudness)
|
||||
}
|
||||
|
||||
// setBand/setBass/... применяют к железу + правят in-memory состояние БЕЗ записи в
|
||||
// DataStore (вызываются на каждое движение слайдера). Запись — один раз в commit()
|
||||
// на onValueChangeFinished, чтобы не спамить хранилище десятками правок за драг.
|
||||
|
||||
fun setBand(index: Int, levelMb: Int) {
|
||||
val eq = equalizer ?: return
|
||||
runCatching { eq.setBandLevel(index.toShort(), levelMb.toShort()) }
|
||||
val s = _state.value
|
||||
_state.value = s.copy(
|
||||
currentPreset = -1, // ручная правка → «свой»
|
||||
bands = s.bands.map { if (it.index == index) it.copy(levelMb = levelMb) else it }
|
||||
)
|
||||
}
|
||||
|
||||
fun setBass(value: Int) {
|
||||
applyBassInternal(value)
|
||||
_state.value = _state.value.copy(bass = value)
|
||||
}
|
||||
|
||||
fun setVirtualizer(value: Int) {
|
||||
applyVirtualizerInternal(value)
|
||||
_state.value = _state.value.copy(virtualizer = value)
|
||||
}
|
||||
|
||||
fun setLoudness(value: Int) {
|
||||
applyLoudnessInternal(value)
|
||||
_state.value = _state.value.copy(loudness = value)
|
||||
}
|
||||
|
||||
/** Сохраняет текущее состояние полос/улучшайзеров (вызывать при отпускании слайдера). */
|
||||
fun commit() {
|
||||
val s = _state.value
|
||||
persist {
|
||||
settings.setEqPreset(s.currentPreset)
|
||||
settings.setEqBands(currentBandsCsv())
|
||||
settings.setEqBass(s.bass)
|
||||
settings.setEqVirtualizer(s.virtualizer)
|
||||
settings.setEqLoudness(s.loudness)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Внутреннее применение к железу ──
|
||||
|
||||
private fun applyBassInternal(value: Int) {
|
||||
val bb = bassBoost ?: return
|
||||
runCatching {
|
||||
bb.enabled = masterEnabled && value > 0
|
||||
if (masterEnabled && value > 0) bb.setStrength((value.coerceIn(0, 100) * 10).toShort())
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyVirtualizerInternal(value: Int) {
|
||||
val vz = virtualizer ?: return
|
||||
runCatching {
|
||||
vz.enabled = masterEnabled && value > 0
|
||||
if (masterEnabled && value > 0) vz.setStrength((value.coerceIn(0, 100) * 10).toShort())
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLoudnessInternal(value: Int) {
|
||||
val le = loudness ?: return
|
||||
runCatching {
|
||||
le.enabled = masterEnabled && value > 0
|
||||
// 0..100 % → 0..1200 мБ (= 0..+12 дБ)
|
||||
if (masterEnabled && value > 0) le.setTargetGain(value.coerceIn(0, 100) * 12)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentBandsCsv(): String {
|
||||
val eq = equalizer ?: return ""
|
||||
return (0 until eq.numberOfBands).joinToString(",") { eq.getBandLevel(it.toShort()).toString() }
|
||||
}
|
||||
|
||||
private fun emitState(enabled: Boolean, preset: Int, bass: Int, virt: Int, loud: Int) {
|
||||
val eq = equalizer
|
||||
val bands = if (eq != null) {
|
||||
val range = eq.bandLevelRange // [min, max] в мБ
|
||||
(0 until eq.numberOfBands).map { i ->
|
||||
val b = i.toShort()
|
||||
EqBand(
|
||||
index = i,
|
||||
centerHz = eq.getCenterFreq(b) / 1000, // мГц → Гц
|
||||
minMb = range[0].toInt(),
|
||||
maxMb = range[1].toInt(),
|
||||
levelMb = eq.getBandLevel(b).toInt()
|
||||
)
|
||||
}
|
||||
} else emptyList()
|
||||
val presets = if (eq != null) {
|
||||
(0 until eq.numberOfPresets).map { eq.getPresetName(it.toShort()) }
|
||||
} else emptyList()
|
||||
_state.value = EqState(
|
||||
available = eq != null,
|
||||
enabled = enabled,
|
||||
bands = bands,
|
||||
presets = presets,
|
||||
currentPreset = preset,
|
||||
hasBass = bassBoost != null,
|
||||
hasVirtualizer = virtualizer != null,
|
||||
hasLoudness = loudness != null,
|
||||
bass = bass,
|
||||
virtualizer = virt,
|
||||
loudness = loud
|
||||
)
|
||||
}
|
||||
|
||||
private fun persist(block: suspend () -> Unit) {
|
||||
scope.launch { runCatching { block() } }
|
||||
}
|
||||
|
||||
fun release() {
|
||||
runCatching { equalizer?.release() }
|
||||
runCatching { bassBoost?.release() }
|
||||
runCatching { virtualizer?.release() }
|
||||
runCatching { loudness?.release() }
|
||||
equalizer = null; bassBoost = null; virtualizer = null; loudness = null
|
||||
}
|
||||
}
|
||||
182
app/src/main/java/com/radiola/service/AudioSpectrum.kt
Normal file
182
app/src/main/java/com/radiola/service/AudioSpectrum.kt
Normal file
@@ -0,0 +1,182 @@
|
||||
package com.radiola.service
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.TeeAudioProcessor
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Итеративный radix-2 FFT (in-place). Размер — степень двойки. */
|
||||
internal object Fft {
|
||||
fun transform(re: FloatArray, im: FloatArray) {
|
||||
val n = re.size
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) {
|
||||
j = j xor bit
|
||||
bit = bit shr 1
|
||||
}
|
||||
j = j or bit
|
||||
if (i < j) {
|
||||
var t = re[i]; re[i] = re[j]; re[j] = t
|
||||
t = im[i]; im[i] = im[j]; im[j] = t
|
||||
}
|
||||
}
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val ang = -2.0 * Math.PI / len
|
||||
val wr = cos(ang).toFloat()
|
||||
val wi = kotlin.math.sin(ang).toFloat()
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
var curR = 1f
|
||||
var curI = 0f
|
||||
val half = len / 2
|
||||
for (k in 0 until half) {
|
||||
val reK = re[i + k + half]
|
||||
val imK = im[i + k + half]
|
||||
val bR = reK * curR - imK * curI
|
||||
val bI = reK * curI + imK * curR
|
||||
val aR = re[i + k]
|
||||
val aI = im[i + k]
|
||||
re[i + k] = aR + bR
|
||||
im[i + k] = aI + bI
|
||||
re[i + k + half] = aR - bR
|
||||
im[i + k + half] = aI - bI
|
||||
val nCurR = curR * wr - curI * wi
|
||||
curI = curR * wi + curI * wr
|
||||
curR = nCurR
|
||||
}
|
||||
i += len
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключается к декодированному PCM в аудио-конвейере ExoPlayer (через
|
||||
* TeeAudioProcessor — без изменения звука и без разрешений) и считает спектр
|
||||
* (FFT → лог-полосы) для «живого» эквалайзера, реагирующего на реальный звук.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class AudioSpectrumAnalyzer(
|
||||
private val bands: Int = 32,
|
||||
) : TeeAudioProcessor.AudioBufferSink {
|
||||
|
||||
private val _spectrum = MutableStateFlow(FloatArray(bands))
|
||||
val spectrum: StateFlow<FloatArray> = _spectrum
|
||||
|
||||
// FFT считаем ТОЛЬКО когда есть наблюдатель (открыт плеер). Иначе ~86 FFT/с
|
||||
// молотят впустую при фоновом проигрывании (экран выключен) — главный
|
||||
// пожиратель батареи. Ставится из UI плеера (VisualizerHost).
|
||||
@Volatile
|
||||
var active: Boolean = false
|
||||
|
||||
// Меньше окно = меньше задержка реакции на удар (групповая задержка Hann ~окно/2)
|
||||
// и чаще обновления. Лайвность держит автогейн, а не размер окна.
|
||||
private val fftSize = 1024
|
||||
private val sample = FloatArray(fftSize)
|
||||
private val re = FloatArray(fftSize)
|
||||
private val im = FloatArray(fftSize)
|
||||
private val window = FloatArray(fftSize) { 0.5f * (1f - cos(2.0 * Math.PI * it / (fftSize - 1)).toFloat()) }
|
||||
private val smoothed = FloatArray(bands)
|
||||
private var filled = 0
|
||||
private var channelCount = 2
|
||||
private var pcm16 = true
|
||||
private var sampleRate = 44100
|
||||
private var lastEmit = 0L
|
||||
// Автогейн: бегущий пик амплитуды — чтобы столбики всегда использовали всю
|
||||
// высоту независимо от громкости трека.
|
||||
private var agcPeak = 1e-4f
|
||||
|
||||
override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) {
|
||||
this.sampleRate = if (sampleRateHz > 0) sampleRateHz else 44100
|
||||
this.channelCount = channelCount.coerceAtLeast(1)
|
||||
this.pcm16 = encoding == C.ENCODING_PCM_16BIT
|
||||
filled = 0
|
||||
}
|
||||
|
||||
override fun handleBuffer(buffer: ByteBuffer) {
|
||||
if (!pcm16) return
|
||||
// Нет наблюдателя — не тратим CPU на FFT (батарея при фоновом проигрывании).
|
||||
if (!active) {
|
||||
filled = 0
|
||||
return
|
||||
}
|
||||
val b = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
|
||||
val ch = channelCount
|
||||
val hop = fftSize / 2
|
||||
while (b.remaining() >= 2 * ch) {
|
||||
var sum = 0f
|
||||
for (c in 0 until ch) sum += b.short.toFloat()
|
||||
sample[filled++] = (sum / ch) / 32768f
|
||||
if (filled >= fftSize) {
|
||||
compute()
|
||||
// Перекрытие 50%: оставляем вторую половину — чаще обновляем (~43к/с),
|
||||
// спектр идёт «впритык» к биту, без рывков.
|
||||
System.arraycopy(sample, hop, sample, 0, fftSize - hop)
|
||||
filled = fftSize - hop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun compute() {
|
||||
for (i in 0 until fftSize) {
|
||||
re[i] = sample[i] * window[i]
|
||||
im[i] = 0f
|
||||
}
|
||||
Fft.transform(re, im)
|
||||
|
||||
val half = fftSize / 2
|
||||
val binHz = sampleRate.toFloat() / fftSize
|
||||
val fMin = 40f
|
||||
val fMax = 16000f
|
||||
val ratio = fMax / fMin
|
||||
|
||||
val raw = FloatArray(bands)
|
||||
var frameMax = 0f
|
||||
for (band in 0 until bands) {
|
||||
// Лог-частотные полосы 40Гц..16кГц, среднее по бинам полосы.
|
||||
val fLo = fMin * Math.pow(ratio.toDouble(), band.toDouble() / bands).toFloat()
|
||||
val fHi = fMin * Math.pow(ratio.toDouble(), (band + 1.0) / bands).toFloat()
|
||||
val binLo = (fLo / binHz).toInt().coerceIn(1, half - 1)
|
||||
val binHi = (fHi / binHz).toInt().coerceIn(binLo + 1, half)
|
||||
var sum = 0f
|
||||
for (bin in binLo until binHi) {
|
||||
sum += sqrt(re[bin] * re[bin] + im[bin] * im[bin])
|
||||
}
|
||||
// Лёгкий подъём верхов (у них меньше энергии) — чтобы спектр был ровнее.
|
||||
val tilt = 1f + 1.5f * (band.toFloat() / bands)
|
||||
val mag = sum / (binHi - binLo) * tilt
|
||||
raw[band] = mag
|
||||
if (mag > frameMax) frameMax = mag
|
||||
}
|
||||
|
||||
// Автогейн: мгновенный рост пика, плавный спад → всегда полная высота.
|
||||
agcPeak = maxOf(agcPeak * 0.94f, frameMax, 1e-4f)
|
||||
val out = FloatArray(bands)
|
||||
for (band in 0 until bands) {
|
||||
// Нормируем по пику + перцептивный лифт (sqrt), чтобы тихое было видно.
|
||||
val v = sqrt((raw[band] / agcPeak).coerceIn(0f, 1f))
|
||||
val prev = smoothed[band]
|
||||
// Мгновенный рост на удар, быстрый спад — чтобы попадать в ритм, не «висеть».
|
||||
smoothed[band] = if (v > prev) v else prev * 0.55f + v * 0.45f
|
||||
out[band] = smoothed[band]
|
||||
}
|
||||
// Считаем сглаживание каждый хоп (плавность), но эмитим в UI ~45/с, чтобы
|
||||
// не перегружать перерисовку плеера.
|
||||
val now = System.nanoTime()
|
||||
if (now - lastEmit >= 22_000_000L) {
|
||||
lastEmit = now
|
||||
_spectrum.value = out
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/radiola/service/BootReceiver.kt
Normal file
36
app/src/main/java/com/radiola/service/BootReceiver.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Перепланирует будильники после перезагрузки устройства.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var alarmDao: AlarmDao
|
||||
|
||||
@Inject
|
||||
lateinit var alarmScheduler: AlarmScheduler
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val pending = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
alarmScheduler.rescheduleAll(alarmDao)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,30 +11,92 @@ import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import android.util.Log
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.media3.exoplayer.audio.TeeAudioProcessor
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@UnstableApi
|
||||
@Singleton
|
||||
class PlayerController @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
@ApplicationContext context: Context,
|
||||
private val sleepSoundPlayer: SleepSoundPlayer,
|
||||
private val audioEffects: AudioEffectsController
|
||||
) {
|
||||
// Анализатор спектра реального звука — для «живого» эквалайзера.
|
||||
private val spectrumAnalyzer = AudioSpectrumAnalyzer()
|
||||
val spectrum: StateFlow<FloatArray> = spectrumAnalyzer.spectrum
|
||||
|
||||
// RenderersFactory, который вставляет наш tee-процессор в аудио-конвейер
|
||||
// (читает декодированный PCM, не меняя звук).
|
||||
private val renderersFactory = object : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioTrackPlaybackParams: Boolean,
|
||||
): AudioSink {
|
||||
return DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
||||
.setAudioProcessors(arrayOf(TeeAudioProcessor(spectrumAnalyzer)))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||||
|
||||
private val _currentStationPrefix = MutableStateFlow<String?>(null)
|
||||
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
|
||||
|
||||
// Id играющей станции — для подсветки активной карточки в списке.
|
||||
private val _currentStationId = MutableStateFlow<Int?>(null)
|
||||
val currentStationId: StateFlow<Int?> = _currentStationId
|
||||
|
||||
private val _icyTitle = MutableStateFlow<String?>(null)
|
||||
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
|
||||
|
||||
// ── Таймер сна ──
|
||||
// Оставшееся время в мс (null = таймер выключен). В последние FADE_MS звук
|
||||
// плавно затухает (экспоненциальная кривая), затем пауза.
|
||||
private val _sleepRemainingMs = MutableStateFlow<Long?>(null)
|
||||
val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow()
|
||||
private val timerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var sleepJob: Job? = null
|
||||
|
||||
// Переподключение при обрыве потока (дорога/туннели).
|
||||
private var retryCount = 0
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private companion object {
|
||||
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
|
||||
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
|
||||
const val SOUND_VOL = 0.6f // комфортная громкость шума
|
||||
const val SOUND_OUTRO_MS = 180_000L // финальное окно со звуком сна (последние ~3 мин)
|
||||
}
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
var onSkipToNext: (() -> Unit)? = null
|
||||
@@ -70,7 +132,19 @@ class PlayerController @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
// HTTP-источник с разрешёнными кросс-протокольными редиректами (http→https):
|
||||
// многие станции отдают 301 c http на https, без этого ExoPlayer их не играет.
|
||||
private val mediaSourceFactory = DefaultMediaSourceFactory(
|
||||
DefaultDataSource.Factory(
|
||||
context,
|
||||
DefaultHttpDataSource.Factory()
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
)
|
||||
|
||||
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setRenderersFactory(renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
@@ -79,11 +153,24 @@ class PlayerController @Inject constructor(
|
||||
true
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
// Держим CPU + Wi-Fi активными, пока играем (partial wakelock + wifilock).
|
||||
// Без этого при выключенном экране система усыпляет сеть → буфер пустеет →
|
||||
// радио глохнет (главная причина «обрыва» в машине по Bluetooth).
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
.apply {
|
||||
addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
_isPlaying.value = playing
|
||||
// Успешно играем — сбрасываем счётчик попыток переподключения.
|
||||
if (playing) retryCount = 0
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
// В дороге сигнал рвётся (туннели, край соты). Не глушим радио
|
||||
// навсегда — пере-готовим поток с нарастающей задержкой.
|
||||
Log.w("PlayerController", "Ошибка плеера: ${error.errorCodeName}, переподключение")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
@@ -112,8 +199,31 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
})
|
||||
// Фиксированная аудиосессия → эффекты (эквалайзер и т.д.) держатся на ней
|
||||
// и переживают смену станций. Привязываем их сразу после создания плеера.
|
||||
val sessionId = audioManager.generateAudioSessionId()
|
||||
runCatching { setAudioSessionId(sessionId) }
|
||||
audioEffects.attach(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Переподключение после ошибки потока с нарастающей задержкой (2с→15с, до 10
|
||||
* попыток ≈ пережить туннель). Счётчик сбрасывается, как только снова заиграло.
|
||||
*/
|
||||
private fun scheduleReconnect() {
|
||||
reconnectJob?.cancel()
|
||||
if (retryCount >= 10) return
|
||||
val delayMs = (2_000L * (retryCount + 1)).coerceAtMost(15_000L)
|
||||
retryCount++
|
||||
reconnectJob = timerScope.launch {
|
||||
delay(delayMs)
|
||||
runCatching {
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val player: Player = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands()
|
||||
@@ -136,8 +246,12 @@ class PlayerController @Inject constructor(
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
|
||||
}
|
||||
|
||||
fun play(url: String, stationPrefix: String, stationName: String) {
|
||||
fun play(url: String, stationPrefix: String, stationName: String, stationId: Int? = null) {
|
||||
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
|
||||
// Новая станция — сбрасываем переподключение предыдущего потока.
|
||||
reconnectJob?.cancel()
|
||||
retryCount = 0
|
||||
_currentStationId.value = stationId
|
||||
_icyTitle.value = null
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(url)
|
||||
@@ -154,6 +268,18 @@ class PlayerController @Inject constructor(
|
||||
_currentStationPrefix.value = stationPrefix
|
||||
}
|
||||
|
||||
/** Сменить URL потока (переключение качества) без потери текущих метаданных/обложки. */
|
||||
fun changeStream(url: String) {
|
||||
Log.d("PlayerController", "changeStream() url=$url")
|
||||
val keepMetadata = exoPlayer.currentMediaItem?.mediaMetadata
|
||||
_icyTitle.value = null
|
||||
val builder = MediaItem.Builder().setUri(url)
|
||||
if (keepMetadata != null) builder.setMediaMetadata(keepMetadata)
|
||||
exoPlayer.setMediaItem(builder.build())
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -170,7 +296,125 @@ class PlayerController @Inject constructor(
|
||||
exoPlayer.replaceMediaItem(0, updatedMediaItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить таймер сна на [durationMs] мс.
|
||||
* Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза.
|
||||
* Со [sound]: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (не больше
|
||||
* половины таймера) включается звук для сна — радио кроссфейдится в шум (радио ↓,
|
||||
* шум ↑), шум держится, в самом конце затухает в тишину. Засыпаешь под радио, а не
|
||||
* под резкий белый шум в первые же полторы минуты.
|
||||
*/
|
||||
fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) {
|
||||
sleepJob?.cancel()
|
||||
exoPlayer.volume = 1f
|
||||
sleepSoundPlayer.stop()
|
||||
sleepJob = timerScope.launch {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val end = start + durationMs
|
||||
// Финальное окно со звуком — не длиннее половины таймера (для коротких).
|
||||
val outro = if (sound != null) SOUND_OUTRO_MS.coerceAtMost(durationMs / 2) else 0L
|
||||
// Кроссфейд радио→шум занимает первую половину аутро.
|
||||
val crossfade = CROSSFADE_MS.coerceAtMost(outro / 2).coerceAtLeast(1L)
|
||||
var soundStarted = false
|
||||
while (true) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val remaining = end - now
|
||||
if (remaining <= 0L) break
|
||||
_sleepRemainingMs.value = remaining
|
||||
when {
|
||||
sound != null && remaining <= outro -> {
|
||||
// Генератор шума стартуем лениво — только в аутро, не весь таймер.
|
||||
if (!soundStarted) {
|
||||
sleepSoundPlayer.start(sound)
|
||||
soundStarted = true
|
||||
}
|
||||
val outroElapsed = outro - remaining
|
||||
when {
|
||||
outroElapsed < crossfade -> {
|
||||
// Кроссфейд: радио вниз, шум вверх.
|
||||
val f = outroElapsed.toFloat() / crossfade
|
||||
exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
|
||||
sleepSoundPlayer.setVolume(f * SOUND_VOL)
|
||||
}
|
||||
remaining <= FADE_MS -> {
|
||||
// Финальное затухание шума в тишину.
|
||||
val frac = remaining.toFloat() / FADE_MS
|
||||
sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL)
|
||||
}
|
||||
else -> {
|
||||
// Радио отыграло — пауза, шум на комфортной громкости.
|
||||
if (exoPlayer.isPlaying) exoPlayer.pause()
|
||||
sleepSoundPlayer.setVolume(SOUND_VOL)
|
||||
}
|
||||
}
|
||||
delay(150)
|
||||
}
|
||||
sound == null && remaining <= FADE_MS -> {
|
||||
// Без звука: экспоненциальное затухание радио в конце.
|
||||
val frac = remaining.toFloat() / FADE_MS
|
||||
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
|
||||
delay(150)
|
||||
}
|
||||
else -> {
|
||||
// Основная фаза: радио играет как обычно.
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (exoPlayer.isPlaying) exoPlayer.pause()
|
||||
exoPlayer.volume = 1f
|
||||
sleepSoundPlayer.stop()
|
||||
_sleepRemainingMs.value = null
|
||||
sleepJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск воспроизведения станции по будильнику: играет [url] и плавно нарастает
|
||||
* громкость 0 → 1 за [fadeInMs] (мягкое пробуждение).
|
||||
*/
|
||||
fun startAlarmPlayback(
|
||||
url: String,
|
||||
prefix: String,
|
||||
name: String,
|
||||
id: Int?,
|
||||
fadeInMs: Long = 60_000L,
|
||||
) {
|
||||
cancelSleepTimer()
|
||||
play(url, prefix, name, id)
|
||||
exoPlayer.volume = 0f
|
||||
sleepJob?.cancel()
|
||||
sleepJob = timerScope.launch {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
while (true) {
|
||||
val elapsed = SystemClock.elapsedRealtime() - start
|
||||
if (elapsed >= fadeInMs) break
|
||||
exoPlayer.volume = (elapsed.toFloat() / fadeInMs).coerceIn(0f, 1f)
|
||||
delay(200)
|
||||
}
|
||||
exoPlayer.volume = 1f
|
||||
sleepJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
|
||||
fun cancelSleepTimer() {
|
||||
sleepJob?.cancel()
|
||||
sleepJob = null
|
||||
exoPlayer.volume = 1f
|
||||
sleepSoundPlayer.stop()
|
||||
_sleepRemainingMs.value = null
|
||||
}
|
||||
|
||||
/** Включить/выключить расчёт спектра (FFT) — только пока открыт плеер. */
|
||||
fun setSpectrumActive(active: Boolean) {
|
||||
spectrumAnalyzer.active = active
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
// Пауза пользователем — отменяем отложенное переподключение, иначе оно
|
||||
// позже само возобновит воспроизведение.
|
||||
reconnectJob?.cancel()
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
@@ -179,12 +423,16 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
reconnectJob?.cancel()
|
||||
exoPlayer.stop()
|
||||
_currentStationPrefix.value = null
|
||||
_currentStationId.value = null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
timerScope.cancel()
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
sleepSoundPlayer.stop()
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.radiola.MainActivity
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.dao.StationDao
|
||||
import com.radiola.data.remote.LoveStreamResolver
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@UnstableApi
|
||||
class PlayerService : MediaSessionService() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_ALARM = "com.radiola.ALARM"
|
||||
private const val CHANNEL_ALARM = "radiola_alarm"
|
||||
private const val NOTIF_ID_ALARM = 9001
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var playerController: PlayerController
|
||||
|
||||
@Inject
|
||||
lateinit var alarmDao: AlarmDao
|
||||
|
||||
@Inject
|
||||
lateinit var stationDao: StationDao
|
||||
|
||||
@Inject
|
||||
lateinit var loveStreamResolver: LoveStreamResolver
|
||||
|
||||
@Inject
|
||||
lateinit var alarmScheduler: AlarmScheduler
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -30,6 +64,88 @@ class PlayerService : MediaSessionService() {
|
||||
)
|
||||
)
|
||||
.build()
|
||||
ensureAlarmChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_ALARM) {
|
||||
val alarmId = intent.getIntExtra("alarm_id", -1)
|
||||
if (alarmId >= 0) handleAlarm(alarmId)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun handleAlarm(alarmId: Int) {
|
||||
// Немедленно поднимаем foreground-уведомление (Android требует ≤5 с после startForegroundService)
|
||||
startForeground(NOTIF_ID_ALARM, buildAlarmNotification())
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val alarm = alarmDao.getById(alarmId)
|
||||
if (alarm == null) {
|
||||
Log.w("PlayerService", "Будильник #$alarmId не найден в БД")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
return@launch
|
||||
}
|
||||
val station = stationDao.getByIdOnce(alarm.stationId)
|
||||
if (station == null) {
|
||||
Log.w("PlayerService", "Станция #${alarm.stationId} не найдена, будильник #$alarmId")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
return@launch
|
||||
}
|
||||
val url = loveStreamResolver.resolve(station.streamUrl)
|
||||
playerController.startAlarmPlayback(
|
||||
url = url,
|
||||
prefix = station.prefix,
|
||||
name = station.name,
|
||||
id = station.id,
|
||||
fadeInMs = alarm.fadeInSec * 1000L
|
||||
)
|
||||
// Перепланируем или деактивируем будильник
|
||||
if (alarm.daysMask != 0) {
|
||||
// Повторяющийся — планируем следующее срабатывание
|
||||
alarmScheduler.schedule(alarm)
|
||||
} else {
|
||||
// Разовый — отключаем
|
||||
alarmDao.setEnabled(alarmId, false)
|
||||
}
|
||||
// MediaSession-уведомление возьмёт на себя отображение воспроизведения
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PlayerService", "Ошибка воспроизведения будильника #$alarmId", e)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAlarmChannel() {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(CHANNEL_ALARM) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ALARM,
|
||||
"Будильник",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Уведомления о срабатывании будильника"
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAlarmNotification(): Notification {
|
||||
val mainIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ALARM)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Будильник")
|
||||
.setContentText("Запуск радио…")
|
||||
.setContentIntent(mainIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
|
||||
@@ -43,6 +159,10 @@ class PlayerService : MediaSessionService() {
|
||||
override fun onDestroy() {
|
||||
mediaSession?.release()
|
||||
mediaSession = null
|
||||
// serviceScope — поле этого сервиса (пере-создаётся при рестарте), отменяем.
|
||||
// playerController — @Singleton (переживает рестарт сервиса), его НЕ релизим:
|
||||
// иначе новый PlayerService построит MediaSession на освобождённом плеере.
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import com.radiola.domain.model.Recording
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Контроллер воспроизведения записей эфира (отдельный ExoPlayer, независимый от радио).
|
||||
*/
|
||||
@Singleton
|
||||
class RecordingPlaybackController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playerController: PlayerController
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
private val _current = MutableStateFlow<Recording?>(null)
|
||||
val current: StateFlow<Recording?> = _current
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||||
|
||||
private val _positionMs = MutableStateFlow(0L)
|
||||
val positionMs: StateFlow<Long> = _positionMs
|
||||
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs
|
||||
|
||||
// Записи эфира — сырые ADTS-AAC/MP3 без контейнера и индексов перемотки.
|
||||
// Включаем CBR-seeking, иначе ExoPlayer считает поток неперематываемым
|
||||
// (seekTo не работал, запись всегда стартовала с начала).
|
||||
private val extractorsFactory = DefaultExtractorsFactory()
|
||||
.setConstantBitrateSeekingEnabled(true)
|
||||
.setConstantBitrateSeekingAlwaysEnabled(true)
|
||||
|
||||
// Плеер создаём ЛЕНИВО — запись играют редко, а раньше второй ExoPlayer висел
|
||||
// в памяти всю сессию у каждого. Освобождаем в stop(): обычно после остановки
|
||||
// пользователь уходит с экрана записей, держать декодер/буферы незачем.
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
_isPlaying.value = playing
|
||||
// Поллер позиции крутится ТОЛЬКО во время игры — раньше цикл 2 Гц
|
||||
// работал всю сессию вхолостую (буст main-loop / батарея).
|
||||
if (playing) startPositionPolling() else stopPositionPolling()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
val dur = exoPlayer?.duration ?: C.TIME_UNSET
|
||||
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
_isPlaying.value = false
|
||||
_positionMs.value = _durationMs.value
|
||||
stopPositionPolling()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var positionPollingJob: Job? = null
|
||||
|
||||
private fun ensurePlayer(): ExoPlayer =
|
||||
exoPlayer ?: ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
|
||||
.build()
|
||||
.also { it.addListener(playerListener); exoPlayer = it }
|
||||
|
||||
private fun startPositionPolling() {
|
||||
if (positionPollingJob?.isActive == true) return
|
||||
positionPollingJob = scope.launch {
|
||||
while (isActive) {
|
||||
val p = exoPlayer ?: break
|
||||
if (p.isPlaying) {
|
||||
_positionMs.value = p.currentPosition
|
||||
val dur = p.duration
|
||||
if (dur != C.TIME_UNSET) _durationMs.value = dur
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPositionPolling() {
|
||||
positionPollingJob?.cancel()
|
||||
positionPollingJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать воспроизведение записи. Сначала останавливает радио.
|
||||
*/
|
||||
fun play(recording: Recording) {
|
||||
// Останавливаем радиоплеер
|
||||
playerController.pause()
|
||||
playerController.stop()
|
||||
|
||||
_current.value = recording
|
||||
_positionMs.value = 0L
|
||||
_durationMs.value = recording.duration ?: 0L
|
||||
|
||||
val player = ensurePlayer()
|
||||
val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath)))
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
|
||||
/** Переключить паузу/воспроизведение. */
|
||||
fun togglePlayPause() {
|
||||
val player = exoPlayer ?: return
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
/** Перейти к позиции в мс. */
|
||||
fun seekTo(ms: Long) {
|
||||
val player = exoPlayer ?: return
|
||||
val target = ms.coerceIn(0L, _durationMs.value.coerceAtLeast(1L))
|
||||
player.seekTo(target)
|
||||
_positionMs.value = target
|
||||
}
|
||||
|
||||
/** Перемотать на deltaMs (может быть отрицательным). */
|
||||
fun seekBy(deltaMs: Long) {
|
||||
seekTo(_positionMs.value + deltaMs)
|
||||
}
|
||||
|
||||
/** Остановить воспроизведение, освободить плеер и сбросить текущую запись. */
|
||||
fun stop() {
|
||||
stopPositionPolling()
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
_current.value = null
|
||||
_isPlaying.value = false
|
||||
_positionMs.value = 0L
|
||||
_durationMs.value = 0L
|
||||
}
|
||||
}
|
||||
120
app/src/main/java/com/radiola/service/SleepSoundPlayer.kt
Normal file
120
app/src/main/java/com/radiola/service/SleepSoundPlayer.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package com.radiola.service
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
/** Тип звука для засыпания (процедурная генерация цветного шума). */
|
||||
enum class SleepSound(val key: String, val title: String) {
|
||||
WHITE("white", "Белый шум"),
|
||||
PINK("pink", "Розовый шум"),
|
||||
BROWN("brown", "Коричневый шум");
|
||||
|
||||
companion object {
|
||||
fun fromKey(key: String?): SleepSound? = entries.firstOrNull { it.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проигрыватель цветного шума для засыпания. Генерирует PCM на отдельном потоке и
|
||||
* пишет в [AudioTrack] (streaming). Громкость регулируется на лету (для fade/кроссфейда).
|
||||
* Розовый — фильтр Пола Келлета, коричневый — интегрированный белый (random walk).
|
||||
*/
|
||||
@Singleton
|
||||
class SleepSoundPlayer @Inject constructor() {
|
||||
|
||||
private val sampleRate = 44100
|
||||
private val bufSize = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(4096)
|
||||
|
||||
@Volatile private var track: AudioTrack? = null
|
||||
@Volatile private var thread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
/** Запустить генерацию шума [sound]. Стартовая громкость 0 — нарастает кроссфейдом. */
|
||||
@Synchronized
|
||||
fun start(sound: SleepSound) {
|
||||
stop()
|
||||
val at = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
)
|
||||
.setBufferSizeInBytes(bufSize * 2)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
at.setVolume(0f)
|
||||
at.play()
|
||||
track = at
|
||||
running = true
|
||||
thread = Thread { generate(at, sound) }.apply { priority = Thread.MIN_PRIORITY; start() }
|
||||
}
|
||||
|
||||
/** Громкость 0..1 (для плавного появления/затухания). */
|
||||
fun setVolume(v: Float) {
|
||||
track?.setVolume(v.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
thread?.let { runCatching { it.join(300) } }
|
||||
thread = null
|
||||
track?.let { t ->
|
||||
runCatching { t.stop() }
|
||||
runCatching { t.release() }
|
||||
}
|
||||
track = null
|
||||
}
|
||||
|
||||
private fun generate(at: AudioTrack, sound: SleepSound) {
|
||||
val n = 2048
|
||||
val buf = ShortArray(n)
|
||||
// Состояние фильтров розового шума (Пол Келлет)
|
||||
var b0 = 0f; var b1 = 0f; var b2 = 0f; var b3 = 0f; var b4 = 0f; var b5 = 0f; var b6 = 0f
|
||||
var lastBrown = 0f
|
||||
while (running) {
|
||||
for (i in 0 until n) {
|
||||
val white = Random.nextFloat() * 2f - 1f
|
||||
val sample = when (sound) {
|
||||
SleepSound.WHITE -> white * 0.35f
|
||||
SleepSound.PINK -> {
|
||||
b0 = 0.99886f * b0 + white * 0.0555179f
|
||||
b1 = 0.99332f * b1 + white * 0.0750759f
|
||||
b2 = 0.96900f * b2 + white * 0.1538520f
|
||||
b3 = 0.86650f * b3 + white * 0.3104856f
|
||||
b4 = 0.55000f * b4 + white * 0.5329522f
|
||||
b5 = -0.7616f * b5 - white * 0.0168980f
|
||||
val pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362f
|
||||
b6 = white * 0.115926f
|
||||
pink * 0.11f
|
||||
}
|
||||
SleepSound.BROWN -> {
|
||||
val brown = (lastBrown + 0.02f * white) / 1.02f
|
||||
lastBrown = brown
|
||||
brown * 3.5f
|
||||
}
|
||||
}
|
||||
buf[i] = (sample.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
}
|
||||
val written = at.write(buf, 0, n)
|
||||
if (written < 0) break
|
||||
}
|
||||
}
|
||||
}
|
||||
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal file
386
app/src/main/java/com/radiola/ui/alarms/AlarmsScreen.kt
Normal file
@@ -0,0 +1,386 @@
|
||||
package com.radiola.ui.alarms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Plus
|
||||
import com.composables.icons.lucide.Trash2
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
/** Экран управления будильниками. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlarmsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AlarmsViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val alarms by viewModel.alarms.collectAsState()
|
||||
val stations by viewModel.stations.collectAsState()
|
||||
|
||||
// Состояние диалога добавления/редактирования
|
||||
var editingAlarm by remember { mutableStateOf<AlarmEntity?>(null) }
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.bgBase)
|
||||
) {
|
||||
// Шапка с кнопкой «Назад»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Назад", tint = colors.textPrimary)
|
||||
}
|
||||
Text(
|
||||
text = "Будильник",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = colors.textPrimary,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
editingAlarm = null
|
||||
showEditor = true
|
||||
}) {
|
||||
Icon(Lucide.Plus, contentDescription = "Добавить будильник", tint = colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
if (alarms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(Lucide.AlarmClock, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(48.dp))
|
||||
Text("Нет будильников", color = colors.textMuted, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Нажмите «+» чтобы добавить",
|
||||
color = colors.textMuted,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(alarms, key = { it.id }) { alarm ->
|
||||
AlarmCard(
|
||||
alarm = alarm,
|
||||
onToggle = { viewModel.toggle(alarm) },
|
||||
onEdit = {
|
||||
editingAlarm = alarm
|
||||
showEditor = true
|
||||
},
|
||||
onDelete = { viewModel.delete(alarm) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог добавления / редактирования
|
||||
if (showEditor) {
|
||||
AlarmEditorSheet(
|
||||
initial = editingAlarm,
|
||||
stations = stations,
|
||||
onSave = { alarm ->
|
||||
viewModel.addOrUpdate(alarm)
|
||||
showEditor = false
|
||||
},
|
||||
onDismiss = { showEditor = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AlarmCard(
|
||||
alarm: AlarmEntity,
|
||||
onToggle: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Время
|
||||
Text(
|
||||
text = "%02d:%02d".format(alarm.hour, alarm.minute),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (alarm.enabled) colors.textPrimary else colors.textMuted
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
// Станция + дни
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = alarm.stationName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (alarm.enabled) colors.textPrimary else colors.textMuted,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = daysSummary(alarm.daysMask),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
// Удалить
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Удалить", tint = colors.textMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Вкл/выкл
|
||||
Switch(
|
||||
checked = alarm.enabled,
|
||||
onCheckedChange = { onToggle() },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.bgBase,
|
||||
checkedTrackColor = colors.accent,
|
||||
uncheckedThumbColor = colors.textMuted,
|
||||
uncheckedTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private val DAY_LABELS = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||
|
||||
private fun daysSummary(mask: Int): String {
|
||||
if (mask == 0) return "Один раз"
|
||||
val all = (1 shl 7) - 1
|
||||
if (mask == all) return "Каждый день"
|
||||
val weekdays = 0b0011111 // Пн-Пт
|
||||
if (mask == weekdays) return "По будням"
|
||||
val weekend = 0b1100000 // Сб-Вс
|
||||
if (mask == weekend) return "По выходным"
|
||||
return DAY_LABELS.filterIndexed { i, _ -> mask and (1 shl i) != 0 }.joinToString(" ")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Нижний лист редактирования / создания будильника.
|
||||
* Использует Material3 TimePicker + выбор станции + чипы дней.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlarmEditorSheet(
|
||||
initial: AlarmEntity?,
|
||||
stations: List<Station>,
|
||||
onSave: (AlarmEntity) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
// Начальные значения
|
||||
val initHour = initial?.hour ?: 7
|
||||
val initMinute = initial?.minute ?: 0
|
||||
var selectedHour by remember { mutableStateOf(initHour) }
|
||||
var selectedMinute by remember { mutableStateOf(initMinute) }
|
||||
var daysMask by remember { mutableStateOf(initial?.daysMask ?: 0) }
|
||||
var selectedStation by remember {
|
||||
mutableStateOf(stations.firstOrNull { it.id == initial?.stationId } ?: stations.firstOrNull())
|
||||
}
|
||||
var fadeInSec by remember { mutableStateOf(initial?.fadeInSec ?: 60) }
|
||||
var stationDropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Обновим станцию если список подгрузился после открытия
|
||||
LaunchedEffect(stations) {
|
||||
if (selectedStation == null) selectedStation = stations.firstOrNull()
|
||||
}
|
||||
|
||||
val timePickerState = rememberTimePickerState(
|
||||
initialHour = initHour,
|
||||
initialMinute = initMinute,
|
||||
is24Hour = true
|
||||
)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = colors.elevated
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (initial == null) "Новый будильник" else "Изменить будильник",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Выбор времени
|
||||
TimePicker(
|
||||
state = timePickerState,
|
||||
colors = TimePickerDefaults.colors(
|
||||
clockDialColor = colors.surface2,
|
||||
selectorColor = colors.accent,
|
||||
timeSelectorSelectedContainerColor = colors.accent,
|
||||
timeSelectorUnselectedContainerColor = colors.surface,
|
||||
timeSelectorSelectedContentColor = colors.bgBase,
|
||||
timeSelectorUnselectedContentColor = colors.textPrimary,
|
||||
periodSelectorBorderColor = colors.border,
|
||||
clockDialSelectedContentColor = colors.bgBase,
|
||||
clockDialUnselectedContentColor = colors.textPrimary
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
// Выбор станции
|
||||
Column {
|
||||
Text("Станция", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(12.dp))
|
||||
.clickable { stationDropdownExpanded = true }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedStation?.name ?: "Выберите станцию",
|
||||
color = if (selectedStation != null) colors.textPrimary else colors.textMuted,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = stationDropdownExpanded,
|
||||
onDismissRequest = { stationDropdownExpanded = false },
|
||||
modifier = Modifier.background(colors.elevated).heightIn(max = 300.dp)
|
||||
) {
|
||||
stations.forEach { station ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(station.name, color = colors.textPrimary) },
|
||||
onClick = {
|
||||
selectedStation = station
|
||||
stationDropdownExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Дни недели
|
||||
Column {
|
||||
Text("Повтор", style = MaterialTheme.typography.labelSmall, color = colors.textMuted, letterSpacing = 1.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
DAY_LABELS.forEachIndexed { i, label ->
|
||||
val selected = daysMask and (1 shl i) != 0
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (selected) colors.accent else colors.surface)
|
||||
.border(1.dp, if (selected) colors.accent else colors.border, RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
daysMask = daysMask xor (1 shl i)
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (daysMask == 0) "Один раз (ближайшее совпадение)" else daysSummary(daysMask),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопки
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.textSecondary),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Text("Отмена")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val station = selectedStation ?: return@Button
|
||||
onSave(
|
||||
AlarmEntity(
|
||||
id = initial?.id ?: 0,
|
||||
hour = timePickerState.hour,
|
||||
minute = timePickerState.minute,
|
||||
daysMask = daysMask,
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
enabled = initial?.enabled ?: true,
|
||||
fadeInSec = fadeInSec
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.bgBase
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Text("Сохранить", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
Normal file
57
app/src/main/java/com/radiola/ui/alarms/AlarmsViewModel.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.radiola.ui.alarms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.data.local.dao.AlarmDao
|
||||
import com.radiola.data.local.entity.AlarmEntity
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.usecase.GetStationsUseCase
|
||||
import com.radiola.service.AlarmScheduler
|
||||
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 AlarmsViewModel @Inject constructor(
|
||||
private val alarmDao: AlarmDao,
|
||||
private val alarmScheduler: AlarmScheduler,
|
||||
getStationsUseCase: GetStationsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val alarms: StateFlow<List<AlarmEntity>> = alarmDao.getAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val stations: StateFlow<List<Station>> = getStationsUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/** Добавить или обновить будильник; если включён — запланировать. */
|
||||
fun addOrUpdate(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
val id = alarmDao.upsert(alarm).toInt()
|
||||
val saved = alarm.copy(id = if (alarm.id == 0) id else alarm.id)
|
||||
if (saved.enabled) alarmScheduler.schedule(saved)
|
||||
else alarmScheduler.cancel(saved.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Переключить включён/выключен. */
|
||||
fun toggle(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
val newEnabled = !alarm.enabled
|
||||
alarmDao.setEnabled(alarm.id, newEnabled)
|
||||
if (newEnabled) alarmScheduler.schedule(alarm.copy(enabled = true))
|
||||
else alarmScheduler.cancel(alarm.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Удалить будильник и отменить планировщик. */
|
||||
fun delete(alarm: AlarmEntity) {
|
||||
viewModelScope.launch {
|
||||
alarmScheduler.cancel(alarm.id)
|
||||
alarmDao.delete(alarm.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
779
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal file
779
app/src/main/java/com/radiola/ui/charts/ChartsScreen.kt
Normal file
@@ -0,0 +1,779 @@
|
||||
package com.radiola.ui.charts
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.composables.icons.lucide.*
|
||||
import com.radiola.domain.model.ChartEntry
|
||||
import com.radiola.domain.model.ChartPeriod
|
||||
import com.radiola.domain.model.ChartTrend
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.StatPoint
|
||||
import com.radiola.domain.model.TrackStats
|
||||
import com.radiola.ui.components.CategoryPicker
|
||||
import com.radiola.ui.components.EmptyState
|
||||
import com.radiola.ui.components.recede
|
||||
import com.radiola.ui.components.recedeFactor
|
||||
import com.radiola.ui.components.PopularityChart
|
||||
import com.radiola.ui.components.crossfadeModel
|
||||
import com.radiola.ui.components.serviceLogoRes
|
||||
import com.radiola.ui.lyrics.LyricsSheet
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import com.radiola.ui.theme.pressScale
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChartsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ChartsViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val period by viewModel.period.collectAsState()
|
||||
val charts by viewModel.charts.collectAsState()
|
||||
val genres by viewModel.genres.collectAsState()
|
||||
val selectedGenre by viewModel.selectedGenre.collectAsState()
|
||||
val isLoadingCharts by viewModel.isLoadingCharts.collectAsState()
|
||||
val selectedStats by viewModel.selectedTrackStats.collectAsState()
|
||||
val isLoadingStats by viewModel.isLoadingStats.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.bgBase)
|
||||
) {
|
||||
// Двухцветный заголовок
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = colors.textPrimary)) { append("Ча") }
|
||||
withStyle(SpanStyle(color = colors.accent)) { append("рты") }
|
||||
},
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 2.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Популярное на всех станциях",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Сегменты периода (стиль FilterChips)
|
||||
PeriodSelector(
|
||||
selected = period,
|
||||
onSelect = viewModel::selectPeriod
|
||||
)
|
||||
|
||||
// Фильтр по жанру (если бэкенд уже накопил жанры)
|
||||
if (genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
|
||||
GenreSelector(
|
||||
genres = genres,
|
||||
selected = selectedGenre,
|
||||
onSelect = viewModel::selectGenre,
|
||||
contentPadding = PaddingValues(start = 64.dp, end = 20.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
CategoryPicker(
|
||||
title = "Стиль музыки",
|
||||
items = genres,
|
||||
selected = selectedGenre,
|
||||
onSelect = viewModel::selectGenre,
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Список чартов
|
||||
if (isLoadingCharts) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = colors.accent)
|
||||
}
|
||||
} else if (charts.isEmpty()) {
|
||||
EmptyState(
|
||||
message = "Чарты пока недоступны",
|
||||
icon = Lucide.TrendingUp
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 88.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = charts,
|
||||
key = { _, entry -> entry.trackId }
|
||||
) { _, entry ->
|
||||
ChartRow(
|
||||
entry = entry,
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
onClick = { viewModel.selectTrack(entry.trackId) }
|
||||
)
|
||||
HorizontalDivider(
|
||||
color = colors.border.copy(alpha = 0.4f),
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Детальная карточка — ModalBottomSheet
|
||||
if (selectedStats != null || isLoadingStats) {
|
||||
TrackDetailSheet(
|
||||
stats = selectedStats,
|
||||
isLoading = isLoadingStats,
|
||||
onDismiss = viewModel::clearSelection,
|
||||
onToggleLike = { viewModel.toggleLike(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Селектор периода ----
|
||||
|
||||
@Composable
|
||||
private fun PeriodSelector(
|
||||
selected: ChartPeriod,
|
||||
onSelect: (ChartPeriod) -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp)
|
||||
) {
|
||||
items(ChartPeriod.entries) { p ->
|
||||
PeriodChip(
|
||||
label = p.label,
|
||||
selected = selected == p,
|
||||
onClick = { onSelect(p) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeriodChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val bg by animateColorAsState(
|
||||
targetValue = if (selected) colors.accent else colors.surface2,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "periodChipBg"
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "periodChipFg"
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = fg,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(bg)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Селектор жанра ----
|
||||
|
||||
@Composable
|
||||
private fun GenreSelector(
|
||||
genres: List<String>,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp)
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val all = remember(genres) { listOf<String?>(null) + genres }
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
itemsIndexed(all, key = { _, g -> g ?: " all" }) { index, g ->
|
||||
Box(modifier = Modifier.recede(recedeFactor(listState, index))) {
|
||||
PeriodChip(
|
||||
label = g ?: "Все",
|
||||
selected = selected == g,
|
||||
onClick = { onSelect(g) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Строка чарта ----
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChartRow(
|
||||
entry: ChartEntry,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Номер ранга
|
||||
val rankColor = when (entry.rank) {
|
||||
1, 2, 3 -> colors.accent
|
||||
else -> colors.textMuted
|
||||
}
|
||||
Text(
|
||||
text = entry.rank.toString(),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
),
|
||||
color = rankColor,
|
||||
modifier = Modifier.width(30.dp)
|
||||
)
|
||||
|
||||
// Обложка трека
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colors.surface2)
|
||||
) {
|
||||
if (entry.coverUrl != null) {
|
||||
AsyncImage(
|
||||
model = crossfadeModel(entry.coverUrl),
|
||||
contentDescription = "${entry.artist} — ${entry.song}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Название + исполнитель
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = entry.song,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = entry.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Правая часть: иконка тренда + число проигрываний
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TrendIcon(trend = entry.trend)
|
||||
Text(
|
||||
text = formatPlays(entry.plays),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrendIcon(trend: ChartTrend) {
|
||||
val colors = RadiolaTheme.colors
|
||||
when (trend) {
|
||||
ChartTrend.UP -> Icon(
|
||||
imageVector = Lucide.TrendingUp,
|
||||
contentDescription = "Рост",
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
ChartTrend.DOWN -> Icon(
|
||||
imageVector = Lucide.TrendingDown,
|
||||
contentDescription = "Падение",
|
||||
tint = colors.live,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
ChartTrend.NEW -> Icon(
|
||||
imageVector = Lucide.Sparkles,
|
||||
contentDescription = "Новинка",
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
ChartTrend.SAME -> Icon(
|
||||
imageVector = Lucide.Minus,
|
||||
contentDescription = "Без изменений",
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Детальная карточка трека ----
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TrackDetailSheet(
|
||||
stats: TrackStats?,
|
||||
isLoading: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onToggleLike: (String) -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showLyrics by remember { mutableStateOf(false) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = colors.elevated,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
if (isLoading || stats == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(280.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = colors.accent)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 32.dp)
|
||||
) {
|
||||
item {
|
||||
// Большая обложка
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.background(colors.surface2)
|
||||
) {
|
||||
if (stats.coverUrl != null) {
|
||||
AsyncImage(
|
||||
model = crossfadeModel(stats.coverUrl),
|
||||
contentDescription = "${stats.artist} — ${stats.song}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Название + исполнитель
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = stats.song,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = colors.textPrimary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.basicMarquee()
|
||||
)
|
||||
Text(
|
||||
text = stats.artist,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (stats.album != null) {
|
||||
Text(
|
||||
text = stats.album,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
// Лейбл · Год (либо дата релиза как запасной вариант)
|
||||
val metaLine = buildList {
|
||||
stats.label?.let { add(it) }
|
||||
stats.year?.let { add(it.toString()) }
|
||||
}.joinToString(" · ").ifEmpty {
|
||||
stats.releaseDate?.let { "Вышел: $it" } ?: ""
|
||||
}
|
||||
if (metaLine.isNotEmpty()) {
|
||||
Text(
|
||||
text = metaLine,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Жанр + стили
|
||||
val genreTags = buildList {
|
||||
stats.genre?.let { add(it) }
|
||||
addAll(stats.styles)
|
||||
}.distinct()
|
||||
if (genreTags.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GenreTags(tags = genreTags)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Ряд метрик
|
||||
MetricsRow(stats)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// График популярности
|
||||
if (stats.playsTimeline.size >= 2) {
|
||||
Text(
|
||||
text = "Популярность за 30 дней",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
PopularityChart(
|
||||
points = stats.playsTimeline,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(90.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colors.surface2)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp)
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
// Кнопка лайка
|
||||
LikeButton(
|
||||
isLiked = stats.isLiked,
|
||||
likesCount = stats.totalLikes,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onToggleLike(stats.trackId)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Кнопка «Текст песни» — открывает встроенный экран
|
||||
OutlinedButton(
|
||||
onClick = { showLyrics = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = colors.textSecondary
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp, colors.border
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.FileText,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Текст песни")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
// Кнопки музыкальных сервисов
|
||||
Text(
|
||||
text = "Слушать в сервисе",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 0.dp)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Сетка сервисов
|
||||
items(DeeplinkService.entries) { service ->
|
||||
ServiceRow(
|
||||
service = service,
|
||||
onClick = {
|
||||
val url = service.buildSearchUrl(stats.artist, stats.song)
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(Intent.createChooser(intent, "Открыть в..."))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка текста песни поверх детальной карточки
|
||||
if (showLyrics && stats != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showLyrics = false },
|
||||
containerColor = colors.bgBase,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
LyricsSheet(
|
||||
artist = stats.artist,
|
||||
song = stats.song
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Чипы жанра/стилей на детальной ----
|
||||
|
||||
@Composable
|
||||
private fun GenreTags(tags: List<String>) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
tags.forEach { tag ->
|
||||
Text(
|
||||
text = tag,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colors.accent.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Метрики трека ----
|
||||
|
||||
@Composable
|
||||
private fun MetricsRow(stats: TrackStats) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
MetricChip(
|
||||
label = formatPlays(stats.totalPlays),
|
||||
description = "проигрываний",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MetricChip(
|
||||
label = formatPlays(stats.totalLikes),
|
||||
description = "лайков",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (stats.peakRank != null) {
|
||||
MetricChip(
|
||||
label = "#${stats.peakRank}",
|
||||
description = "пик",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricChip(label: String, description: String, modifier: Modifier = Modifier) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(colors.surface2)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Кнопка лайка ----
|
||||
|
||||
@Composable
|
||||
private fun LikeButton(isLiked: Boolean, likesCount: Int, onClick: () -> Unit) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val heartColor by animateColorAsState(
|
||||
targetValue = if (isLiked) colors.live else colors.textMuted,
|
||||
animationSpec = tween(Motion.Fast),
|
||||
label = "heartColor"
|
||||
)
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(colors.surface2)
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isLiked) Lucide.Heart else Lucide.Heart,
|
||||
contentDescription = if (isLiked) "Убрать лайк" else "Поставить лайк",
|
||||
tint = heartColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = if (isLiked) "Нравится · ${formatPlays(likesCount)}" else "Нравится · ${formatPlays(likesCount)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = heartColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Строка сервиса ----
|
||||
|
||||
@Composable
|
||||
private fun ServiceRow(service: DeeplinkService, onClick: () -> Unit) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.surface2),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val logoRes = serviceLogoRes(service)
|
||||
if (logoRes != null) {
|
||||
Icon(
|
||||
painter = androidx.compose.ui.res.painterResource(logoRes),
|
||||
contentDescription = service.displayName,
|
||||
tint = colors.textSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Lucide.Music,
|
||||
contentDescription = service.displayName,
|
||||
tint = colors.textSecondary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = service.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = Lucide.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Утилиты ----
|
||||
|
||||
/** Форматирует число проигрываний: 1234 → «1.2k», 1_000_000 → «1.0M». */
|
||||
private fun formatPlays(value: Int): String {
|
||||
val df = DecimalFormat("#.#")
|
||||
return when {
|
||||
value >= 1_000_000 -> "${df.format(value / 1_000_000.0)}M"
|
||||
value >= 1_000 -> "${df.format(value / 1_000.0)}k"
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal file
119
app/src/main/java/com/radiola/ui/charts/ChartsViewModel.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package com.radiola.ui.charts
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.ChartEntry
|
||||
import com.radiola.domain.model.ChartPeriod
|
||||
import com.radiola.domain.model.TrackStats
|
||||
import com.radiola.domain.repository.ChartsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ChartsViewModel @Inject constructor(
|
||||
private val chartsRepository: ChartsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _period = MutableStateFlow(ChartPeriod.WEEK)
|
||||
val period: StateFlow<ChartPeriod> = _period.asStateFlow()
|
||||
|
||||
private val _charts = MutableStateFlow<List<ChartEntry>>(emptyList())
|
||||
val charts: StateFlow<List<ChartEntry>> = _charts.asStateFlow()
|
||||
|
||||
/** Доступные жанры для фильтра (с бэкенда). */
|
||||
private val _genres = MutableStateFlow<List<String>>(emptyList())
|
||||
val genres: StateFlow<List<String>> = _genres.asStateFlow()
|
||||
|
||||
/** Выбранный жанр (null — «Все»). */
|
||||
private val _selectedGenre = MutableStateFlow<String?>(null)
|
||||
val selectedGenre: StateFlow<String?> = _selectedGenre.asStateFlow()
|
||||
|
||||
private val _isLoadingCharts = MutableStateFlow(false)
|
||||
val isLoadingCharts: StateFlow<Boolean> = _isLoadingCharts.asStateFlow()
|
||||
|
||||
private val _selectedTrackStats = MutableStateFlow<TrackStats?>(null)
|
||||
val selectedTrackStats: StateFlow<TrackStats?> = _selectedTrackStats.asStateFlow()
|
||||
|
||||
private val _isLoadingStats = MutableStateFlow(false)
|
||||
val isLoadingStats: StateFlow<Boolean> = _isLoadingStats.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCharts()
|
||||
loadGenres()
|
||||
}
|
||||
|
||||
fun selectPeriod(newPeriod: ChartPeriod) {
|
||||
if (_period.value == newPeriod) return
|
||||
_period.value = newPeriod
|
||||
loadCharts()
|
||||
}
|
||||
|
||||
fun selectGenre(genre: String?) {
|
||||
if (_selectedGenre.value == genre) return
|
||||
_selectedGenre.value = genre
|
||||
loadCharts()
|
||||
}
|
||||
|
||||
fun selectTrack(trackId: String) {
|
||||
viewModelScope.launch {
|
||||
_isLoadingStats.value = true
|
||||
_selectedTrackStats.value = null
|
||||
try {
|
||||
val stats = chartsRepository.getTrackStats(trackId)
|
||||
_selectedTrackStats.value = stats
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChartsViewModel", "Ошибка загрузки статистики трека $trackId", e)
|
||||
} finally {
|
||||
_isLoadingStats.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_selectedTrackStats.value = null
|
||||
}
|
||||
|
||||
fun toggleLike(trackId: String) {
|
||||
val stats = _selectedTrackStats.value ?: return
|
||||
val newLiked = !stats.isLiked
|
||||
// Оптимистично обновляем UI
|
||||
_selectedTrackStats.value = stats.copy(
|
||||
isLiked = newLiked,
|
||||
totalLikes = if (newLiked) stats.totalLikes + 1 else stats.totalLikes - 1
|
||||
)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
chartsRepository.setLiked(trackId, newLiked)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChartsViewModel", "Ошибка переключения лайка трека $trackId", e)
|
||||
// Откатываем при ошибке
|
||||
_selectedTrackStats.value = stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharts() {
|
||||
viewModelScope.launch {
|
||||
_isLoadingCharts.value = true
|
||||
try {
|
||||
_charts.value = chartsRepository.getCharts(_period.value, _selectedGenre.value)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChartsViewModel", "Ошибка загрузки чартов", e)
|
||||
_charts.value = emptyList()
|
||||
} finally {
|
||||
_isLoadingCharts.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadGenres() {
|
||||
viewModelScope.launch {
|
||||
_genres.value = chartsRepository.getGenres()
|
||||
}
|
||||
}
|
||||
}
|
||||
183
app/src/main/java/com/radiola/ui/components/CategoryPicker.kt
Normal file
183
app/src/main/java/com/radiola/ui/components/CategoryPicker.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Check
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.SlidersHorizontal
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
/**
|
||||
* Кнопка-«быстрый выбор категории» рядом с чипами: круглая (визуально отличается от
|
||||
* чипов-пилюль), по нажатию открывает шторку со ПОЛНЫМ списком категорий + поиском,
|
||||
* чтобы не листать чипы. Drop-in: сам держит состояние шторки.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryPicker(
|
||||
title: String,
|
||||
items: List<String>,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
allLabel: String = "Все"
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
var show by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.surface2)
|
||||
.border(1.5.dp, colors.accent, CircleShape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { show = true }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.SlidersHorizontal,
|
||||
contentDescription = title,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (show) {
|
||||
CategorySheet(
|
||||
title = title,
|
||||
items = items,
|
||||
selected = selected,
|
||||
allLabel = allLabel,
|
||||
onSelect = onSelect,
|
||||
onDismiss = { show = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategorySheet(
|
||||
title: String,
|
||||
items: List<String>,
|
||||
selected: String?,
|
||||
allLabel: String,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(items, query) {
|
||||
val q = query.trim()
|
||||
if (q.isBlank()) items else items.filter { it.contains(q, ignoreCase = true) }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = colors.elevated,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colors.textPrimary,
|
||||
modifier = Modifier.padding(bottom = 14.dp)
|
||||
)
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Поиск…",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 460.dp)) {
|
||||
if (query.isBlank()) {
|
||||
item {
|
||||
CategoryRow(allLabel, selected == null) {
|
||||
onSelect(null); onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
items(filtered) { item ->
|
||||
CategoryRow(item, selected == item) {
|
||||
onSelect(item); onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selected) colors.accent else colors.textPrimary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Lucide.Check,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
78
app/src/main/java/com/radiola/ui/components/FlipCover.kt
Normal file
78
app/src/main/java/com/radiola/ui/components/FlipCover.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
/**
|
||||
* Обложка с эффектом 3D-переворота при смене изображения — как будто
|
||||
* перелистывается страница альбома / пластинка. Старая обложка «улетает»
|
||||
* передней стороной (0–90°), новая «прилетает» задней (90–180°).
|
||||
*/
|
||||
@Composable
|
||||
fun FlipCover(
|
||||
model: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
var current by remember { mutableStateOf(model) }
|
||||
var previous by remember { mutableStateOf(model) }
|
||||
val rotation = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(model) {
|
||||
if (model != current) {
|
||||
previous = current
|
||||
current = model
|
||||
rotation.snapTo(0f)
|
||||
rotation.animateTo(180f, animationSpec = tween(620, easing = FastOutSlowInEasing))
|
||||
// Оседаем: новая обложка становится «лицом», угол 0 — без рывка.
|
||||
previous = current
|
||||
rotation.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
val angle = rotation.value
|
||||
val showFront = angle <= 90f
|
||||
val faceModel = if (showFront) previous else current
|
||||
|
||||
Box(
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationY = angle
|
||||
cameraDistance = 16f * density
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Заднюю грань контр-вращаем, чтобы изображение не было зеркальным.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { rotationY = if (showFront) 0f else 180f },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!faceModel.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = faceModel,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Search
|
||||
import com.composables.icons.lucide.X
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
@Composable
|
||||
@@ -22,6 +37,7 @@ fun SearchBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val haptics = LocalHapticFeedback.current
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
@@ -29,6 +45,31 @@ fun SearchBar(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
placeholder = { Text(placeholder, color = colors.textMuted) },
|
||||
leadingIcon = { Icon(Lucide.Search, contentDescription = null, tint = colors.textMuted) },
|
||||
// Кнопка «очистить» — появляется/исчезает с анимацией (scale + fade).
|
||||
trailingIcon = {
|
||||
AnimatedVisibility(
|
||||
visible = query.isNotEmpty(),
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.X,
|
||||
contentDescription = "Очистить",
|
||||
tint = colors.textSecondary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.padding(7.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onQueryChange("")
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = colors.surface,
|
||||
|
||||
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal file
133
app/src/main/java/com/radiola/ui/components/Sparkline.kt
Normal file
@@ -0,0 +1,133 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.radiola.domain.model.StatPoint
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Компонент-спарклайн: сглаженный линейный график с градиентной заливкой.
|
||||
* Используется для отображения популярности трека (проигрывания / лайки).
|
||||
* Не показывает оси — только форму данных.
|
||||
*/
|
||||
@Composable
|
||||
fun PopularityChart(
|
||||
points: List<StatPoint>,
|
||||
modifier: Modifier = Modifier,
|
||||
lineColor: Color = RadiolaTheme.colors.accent
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val dateFmt = remember { SimpleDateFormat("d MMM", Locale("ru")) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (points.size >= 2) {
|
||||
val minVal = points.minOf { it.value }.toFloat()
|
||||
val maxVal = points.maxOf { it.value }.toFloat()
|
||||
val range = (maxVal - minVal).coerceAtLeast(1f)
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val topPad = 4.dp.toPx()
|
||||
val botPad = 4.dp.toPx()
|
||||
val drawH = h - topPad - botPad
|
||||
|
||||
// Вычисляем координаты точек
|
||||
fun xAt(i: Int) = i * w / (points.size - 1)
|
||||
fun yAt(v: Float) = topPad + drawH * (1f - (v - minVal) / range)
|
||||
|
||||
// Сглаженный path через cubic bezier
|
||||
val linePath = Path()
|
||||
linePath.moveTo(xAt(0), yAt(points[0].value.toFloat()))
|
||||
for (i in 1 until points.size) {
|
||||
val x0 = xAt(i - 1)
|
||||
val y0 = yAt(points[i - 1].value.toFloat())
|
||||
val x1 = xAt(i)
|
||||
val y1 = yAt(points[i].value.toFloat())
|
||||
val cx = (x0 + x1) / 2f
|
||||
linePath.cubicTo(cx, y0, cx, y1, x1, y1)
|
||||
}
|
||||
|
||||
// Заливка под графиком
|
||||
val fillPath = Path().apply {
|
||||
addPath(linePath)
|
||||
lineTo(xAt(points.size - 1), h)
|
||||
lineTo(xAt(0), h)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
path = fillPath,
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(lineColor.copy(alpha = 0.28f), Color.Transparent),
|
||||
startY = topPad,
|
||||
endY = h
|
||||
)
|
||||
)
|
||||
|
||||
// Линия графика
|
||||
drawPath(
|
||||
path = linePath,
|
||||
color = lineColor,
|
||||
style = Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
join = StrokeJoin.Round
|
||||
)
|
||||
)
|
||||
|
||||
// Точка первого и последнего значения
|
||||
drawCircle(
|
||||
color = lineColor,
|
||||
radius = 3.dp.toPx(),
|
||||
center = Offset(xAt(0), yAt(points.first().value.toFloat()))
|
||||
)
|
||||
drawCircle(
|
||||
color = lineColor,
|
||||
radius = 3.dp.toPx(),
|
||||
center = Offset(xAt(points.size - 1), yAt(points.last().value.toFloat()))
|
||||
)
|
||||
}
|
||||
|
||||
// Подписи дат по краям
|
||||
val firstDate = dateFmt.format(Date(points.first().date))
|
||||
val lastDate = dateFmt.format(Date(points.last().date))
|
||||
|
||||
Text(
|
||||
text = firstDate,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 4.dp, bottom = 2.dp)
|
||||
)
|
||||
Text(
|
||||
text = lastDate,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 4.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
// При 0 или 1 точке — ничего не рисуем (корректная пустая обработка)
|
||||
}
|
||||
}
|
||||
96
app/src/main/java/com/radiola/ui/components/SplashOverlay.kt
Normal file
96
app/src/main/java/com/radiola/ui/components/SplashOverlay.kt
Normal file
@@ -0,0 +1,96 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.radiola.ui.theme.RadiolaWordmark
|
||||
import com.radiola.ui.theme.ThemePalette
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Свой экран загрузки: тематический 3D-логотип с акцентным свечением под цвет темы,
|
||||
* мягкой тенью и плавным появлением. Рисуем сами (а не системный сплэш), потому что
|
||||
* Android 12+ не даёт менять иконку системного сплэша под выбранную пользователем тему.
|
||||
*/
|
||||
@Composable
|
||||
fun SplashOverlay(palette: ThemePalette, modifier: Modifier = Modifier) {
|
||||
val colors = palette.colors
|
||||
// Появление снапное: лого видно почти сразу (короткий fade), затем мягкий «вдох».
|
||||
val scale = remember { Animatable(0.92f) }
|
||||
val fade = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch { fade.animateTo(1f, tween(200)) }
|
||||
launch { scale.animateTo(1f, tween(420, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(colors.bgBase),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Акцентное свечение под цвет темы
|
||||
Box(
|
||||
Modifier
|
||||
.size(380.dp)
|
||||
.graphicsLayer { alpha = fade.value }
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(
|
||||
colors.accent.copy(alpha = 0.32f),
|
||||
colors.accent.copy(alpha = 0.10f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
// Мягкая тень логотипа
|
||||
Image(
|
||||
painter = painterResource(palette.logoRes),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f)),
|
||||
modifier = Modifier
|
||||
.size(176.dp)
|
||||
.scale(scale.value)
|
||||
.offset(y = 14.dp)
|
||||
.blur(18.dp)
|
||||
.graphicsLayer { alpha = fade.value }
|
||||
)
|
||||
// Логотип
|
||||
Image(
|
||||
painter = painterResource(palette.logoRes),
|
||||
contentDescription = "radiOLA",
|
||||
modifier = Modifier
|
||||
.size(176.dp)
|
||||
.scale(scale.value)
|
||||
.graphicsLayer { alpha = fade.value }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Box(Modifier.graphicsLayer { alpha = fade.value }) {
|
||||
RadiolaWordmark(fontSize = 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -14,7 +20,15 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
@@ -23,8 +37,8 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Radio
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import com.radiola.ui.theme.pressScale
|
||||
@@ -35,7 +49,10 @@ fun StationCard(
|
||||
isFavorite: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
nowTrack: Track? = null,
|
||||
isCurrent: Boolean = false,
|
||||
isPlaying: Boolean = false
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val haptics = LocalHapticFeedback.current
|
||||
@@ -54,32 +71,120 @@ fun StationCard(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface2)
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!station.coverUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = crossfadeModel(station.coverUrl),
|
||||
contentDescription = station.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Lucide.Radio,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.align(Alignment.Center).size(34.dp)
|
||||
// Свечение активной станции — позади обложки, мягко вылезает из-под краёв.
|
||||
if (isCurrent) {
|
||||
PlayingGlow(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = colors.accent,
|
||||
playing = isPlaying
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface2)
|
||||
) {
|
||||
val trackCover = nowTrack?.coverUrl?.takeIf { it.isNotBlank() }
|
||||
// Фон карточки: обложка трека → логотип станции → фирменная плитка.
|
||||
when {
|
||||
trackCover != null -> {
|
||||
AsyncImage(
|
||||
model = crossfadeModel(trackCover),
|
||||
contentDescription = nowTrack.song,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
!station.coverUrl.isNullOrBlank() -> {
|
||||
AsyncImage(
|
||||
model = crossfadeModel(station.coverUrl),
|
||||
contentDescription = station.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(stationTileBrush(station.name)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stationInitials(station.name),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Black,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Подпись играющего трека — поверх любого фона, если трек известен.
|
||||
if (nowTrack != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
0.5f to Color.Transparent,
|
||||
1f to Color.Black.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = nowTrack.song,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = nowTrack.artist,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
// Бейдж активной станции: эквалайзер в углу обложки.
|
||||
if (isCurrent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(10.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.padding(horizontal = 7.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
com.radiola.ui.theme.LiveEqualizer(
|
||||
modifier = Modifier.size(width = 16.dp, height = 12.dp),
|
||||
barCount = 4,
|
||||
color = colors.accent,
|
||||
playing = isPlaying
|
||||
)
|
||||
}
|
||||
}
|
||||
// Кнопка сердечка — поверх всего, top-end.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(10.dp)
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onFavoriteClick()
|
||||
@@ -93,6 +198,7 @@ fun StationCard(
|
||||
modifier = Modifier.size(17.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
@@ -114,3 +220,76 @@ fun StationCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Мягкое радиальное свечение играющей станции — рисуется ПОЗАДИ обложки и
|
||||
* вылезает из-под её краёв. Центр градиента «гуляет», размер дышит.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayingGlow(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color,
|
||||
playing: Boolean
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "glow")
|
||||
val t by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (2f * Math.PI).toFloat(),
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(4200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "glowT"
|
||||
)
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = if (playing) 1.05f else 1.0f,
|
||||
targetValue = if (playing) 1.20f else 1.07f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(2200, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "glowPulse"
|
||||
)
|
||||
// Целые гармоники (1 и 2) → значения совпадают на t=0 и t=2π, петля бесшовна.
|
||||
val cx = 0.5f + 0.22f * kotlin.math.cos(t)
|
||||
val cy = 0.5f + 0.20f * kotlin.math.sin(2f * t)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.scale(pulse)
|
||||
.blur(28.dp, BlurredEdgeTreatment.Unbounded)
|
||||
.drawBehind {
|
||||
val brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
color.copy(alpha = 0.85f),
|
||||
color.copy(alpha = 0.35f),
|
||||
Color.Transparent
|
||||
),
|
||||
center = Offset(size.width * cx, size.height * cy),
|
||||
radius = size.minDimension * 0.72f
|
||||
)
|
||||
drawRoundRect(
|
||||
brush = brush,
|
||||
cornerRadius = CornerRadius(22.dp.toPx(), 22.dp.toPx())
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Инициалы станции для плитки-плейсхолдера (1–2 символа). */
|
||||
private fun stationInitials(name: String): String {
|
||||
val words = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
return when {
|
||||
words.isEmpty() -> "?"
|
||||
words.size == 1 -> words[0].take(2).uppercase()
|
||||
else -> (words[0].take(1) + words[1].take(1)).uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/** Детерминированный фирменный градиент плитки по названию станции. */
|
||||
private fun stationTileBrush(name: String): Brush {
|
||||
val h = (name.hashCode().toLong() and 0xFFFFFFFFL)
|
||||
val hue = (h % 360L).toFloat()
|
||||
val c1 = Color.hsv(hue, 0.55f, 0.45f)
|
||||
val c2 = Color.hsv((hue + 28f) % 360f, 0.6f, 0.30f)
|
||||
return Brush.linearGradient(listOf(c1, c2))
|
||||
}
|
||||
|
||||
150
app/src/main/java/com/radiola/ui/components/Visualizer.kt
Normal file
150
app/src/main/java/com/radiola/ui/components/Visualizer.kt
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.radiola.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/** Стиль анимации воспроизведения (выбирается пользователем в настройках). */
|
||||
enum class VisualizerStyle(val key: String, val label: String) {
|
||||
BARS_CENTER("bars_center", "Центр"),
|
||||
BARS_BOTTOM("bars_bottom", "Снизу"),
|
||||
WAVE("wave", "Волна"),
|
||||
RADIAL("radial", "Круг");
|
||||
|
||||
companion object {
|
||||
fun fromKey(k: String?): VisualizerStyle = entries.firstOrNull { it.key == k } ?: BARS_CENTER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Визуализатор звука выбранного стиля. levels — реальный спектр (0..1 по полосам);
|
||||
* если его нет (пауза/превью) — мягкая «дышащая» анимация.
|
||||
*/
|
||||
@Composable
|
||||
fun Visualizer(
|
||||
style: VisualizerStyle,
|
||||
levels: FloatArray?,
|
||||
playing: Boolean,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val phase by rememberInfiniteTransition(label = "viz").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = (Math.PI * 2).toFloat(),
|
||||
animationSpec = infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "vizPhase",
|
||||
)
|
||||
Canvas(modifier = modifier) {
|
||||
when (style) {
|
||||
VisualizerStyle.BARS_CENTER -> drawBars(levels, phase, playing, color, centered = true)
|
||||
VisualizerStyle.BARS_BOTTOM -> drawBars(levels, phase, playing, color, centered = false)
|
||||
VisualizerStyle.WAVE -> drawWave(levels, phase, playing, color)
|
||||
VisualizerStyle.RADIAL -> drawRadial(levels, phase, playing, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Уровень полосы i из n: реальный спектр → иначе «дышащая» синус-волна. */
|
||||
private fun levelAt(i: Int, n: Int, levels: FloatArray?, phase: Float, playing: Boolean): Float {
|
||||
if (!playing) return 0.16f
|
||||
if (levels != null && levels.isNotEmpty()) {
|
||||
val idx = (i * levels.size / n).coerceIn(0, levels.size - 1)
|
||||
return (0.06f + 0.94f * levels[idx]).coerceIn(0f, 1f)
|
||||
}
|
||||
return 0.42f + 0.5f * abs(sin(phase + i * 0.7f))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBars(
|
||||
levels: FloatArray?,
|
||||
phase: Float,
|
||||
playing: Boolean,
|
||||
color: Color,
|
||||
centered: Boolean,
|
||||
) {
|
||||
val barCount = 36
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = (size.width - gap * (barCount - 1)) / barCount
|
||||
val maxH = size.height
|
||||
for (i in 0 until barCount) {
|
||||
val h = maxH * levelAt(i, barCount, levels, phase, playing)
|
||||
val x = i * (barWidth + gap)
|
||||
val y = if (centered) (maxH - h) / 2f else (maxH - h)
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(barWidth, h),
|
||||
cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawWave(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) {
|
||||
val points = 48
|
||||
val maxH = size.height
|
||||
val stepX = size.width / (points - 1)
|
||||
val ys = FloatArray(points) { i -> maxH * (1f - levelAt(i, points, levels, phase, playing)) }
|
||||
|
||||
val line = Path()
|
||||
val fill = Path()
|
||||
line.moveTo(0f, ys[0])
|
||||
fill.moveTo(0f, maxH)
|
||||
fill.lineTo(0f, ys[0])
|
||||
for (i in 1 until points) {
|
||||
val x = i * stepX
|
||||
val px = (i - 1) * stepX
|
||||
val midX = (px + x) / 2f
|
||||
// Сглаживание кубическими кривыми между точками.
|
||||
line.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i])
|
||||
fill.cubicTo(midX, ys[i - 1], midX, ys[i], x, ys[i])
|
||||
}
|
||||
fill.lineTo(size.width, maxH)
|
||||
fill.close()
|
||||
drawPath(fill, color = color.copy(alpha = 0.18f))
|
||||
drawPath(line, color = color, style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawRadial(levels: FloatArray?, phase: Float, playing: Boolean, color: Color) {
|
||||
val n = 40
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val rInner = minOf(cx, cy) * 0.42f
|
||||
val rMax = minOf(cx, cy) * 0.98f
|
||||
val stroke = (2 * Math.PI * rInner / n / 2).toFloat().coerceIn(2f, 6f)
|
||||
// тонкое кольцо-основа
|
||||
drawCircle(color = color.copy(alpha = 0.25f), radius = rInner * 0.9f, center = Offset(cx, cy), style = Stroke(1.5.dp.toPx()))
|
||||
for (i in 0 until n) {
|
||||
val lv = levelAt(i, n, levels, phase, playing)
|
||||
val ang = (i.toFloat() / n) * (2 * Math.PI).toFloat() - (Math.PI / 2).toFloat()
|
||||
val len = rInner + (rMax - rInner) * lv
|
||||
val sx = cx + cos(ang) * rInner
|
||||
val sy = cy + sin(ang) * rInner
|
||||
val ex = cx + cos(ang) * len
|
||||
val ey = cy + sin(ang) * len
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(sx, sy),
|
||||
end = Offset(ex, ey),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal file
311
app/src/main/java/com/radiola/ui/equalizer/EqualizerScreen.kt
Normal file
@@ -0,0 +1,311 @@
|
||||
package com.radiola.ui.equalizer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.ChevronLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.radiola.service.EqBand
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
@Composable
|
||||
fun EqualizerScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: EqualizerViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val state by viewModel.state.collectAsState()
|
||||
val on = state.enabled
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Шапка с кнопкой «назад»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Lucide.ChevronLeft,
|
||||
contentDescription = "Назад",
|
||||
tint = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onNavigateBack() }
|
||||
.padding(8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Эквалайзер",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.available) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Эквалайзер недоступен на этом устройстве",
|
||||
color = colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// Главный тумблер
|
||||
Card(colors) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Эквалайзер",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
"Тонкая настройка звука и улучшайзеры",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = on,
|
||||
onCheckedChange = { viewModel.setEnabled(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = colors.bgBase,
|
||||
checkedTrackColor = colors.accent,
|
||||
uncheckedThumbColor = colors.textMuted,
|
||||
uncheckedTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Пресеты
|
||||
if (state.presets.isNotEmpty()) {
|
||||
Label("ПРЕСЕТЫ")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
state.presets.forEachIndexed { index, name ->
|
||||
val selected = state.currentPreset == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (selected) colors.accent else colors.surface2)
|
||||
.clickable(enabled = on) { viewModel.selectPreset(index) }
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
// «Свой» — активен, когда полосы правились вручную
|
||||
val custom = state.currentPreset == -1
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (custom) colors.accent else colors.surface2)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Свой",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (custom) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = if (custom) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Полосы эквалайзера
|
||||
Label("ЧАСТОТЫ")
|
||||
Card(colors) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
state.bands.forEach { band ->
|
||||
BandRow(
|
||||
band = band,
|
||||
enabled = on,
|
||||
colors = colors,
|
||||
onChange = { viewModel.setBand(band.index, it) },
|
||||
onCommit = { viewModel.commit() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Улучшайзеры
|
||||
Label("УЛУЧШАЙЗЕРЫ")
|
||||
Card(colors) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (state.hasBass) {
|
||||
EnhancerSlider("Bass Boost", "Усиление баса", state.bass, on, colors,
|
||||
{ viewModel.setBass(it) }, { viewModel.commit() })
|
||||
}
|
||||
if (state.hasVirtualizer) {
|
||||
EnhancerSlider("Virtualizer", "Объём / ширина стерео", state.virtualizer, on, colors,
|
||||
{ viewModel.setVirtualizer(it) }, { viewModel.commit() })
|
||||
}
|
||||
if (state.hasLoudness) {
|
||||
EnhancerSlider("Громкость", "Подъём тихих станций (до +12 дБ)", state.loudness, on, colors,
|
||||
{ viewModel.setLoudness(it) }, { viewModel.commit() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Card(colors: com.radiola.ui.theme.RadiolaColors, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Label(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = RadiolaTheme.colors.textMuted,
|
||||
letterSpacing = 1.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BandRow(
|
||||
band: EqBand,
|
||||
enabled: Boolean,
|
||||
colors: com.radiola.ui.theme.RadiolaColors,
|
||||
onChange: (Int) -> Unit,
|
||||
onCommit: () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = freqLabel(band.centerHz),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.width(60.dp)
|
||||
)
|
||||
Slider(
|
||||
value = band.levelMb.toFloat(),
|
||||
onValueChange = { onChange(it.toInt()) },
|
||||
onValueChangeFinished = onCommit,
|
||||
valueRange = band.minMb.toFloat()..band.maxMb.toFloat(),
|
||||
enabled = enabled,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "%+d dB".format(band.levelMb / 100),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.accent,
|
||||
modifier = Modifier.width(52.dp),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnhancerSlider(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
colors: com.radiola.ui.theme.RadiolaColors,
|
||||
onChange: (Int) -> Unit,
|
||||
onCommit: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = colors.textPrimary)
|
||||
Text(subtitle, style = MaterialTheme.typography.labelSmall, color = colors.textSecondary)
|
||||
}
|
||||
Text(
|
||||
"$value%",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value.toFloat(),
|
||||
onValueChange = { onChange(it.toInt()) },
|
||||
onValueChangeFinished = onCommit,
|
||||
valueRange = 0f..100f,
|
||||
enabled = enabled,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun freqLabel(hz: Int): String =
|
||||
if (hz >= 1000) "%.1f kHz".format(hz / 1000f) else "$hz Hz"
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.radiola.ui.equalizer
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.radiola.service.AudioEffectsController
|
||||
import com.radiola.service.EqState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EqualizerViewModel @Inject constructor(
|
||||
private val audioEffects: AudioEffectsController
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<EqState> = audioEffects.state
|
||||
|
||||
fun setEnabled(on: Boolean) = audioEffects.setEnabled(on)
|
||||
fun selectPreset(index: Int) = audioEffects.selectPreset(index)
|
||||
fun setBand(index: Int, levelMb: Int) = audioEffects.setBand(index, levelMb)
|
||||
fun setBass(value: Int) = audioEffects.setBass(value)
|
||||
fun setVirtualizer(value: Int) = audioEffects.setVirtualizer(value)
|
||||
fun setLoudness(value: Int) = audioEffects.setLoudness(value)
|
||||
fun commit() = audioEffects.commit()
|
||||
}
|
||||
@@ -34,6 +34,9 @@ fun FavoritesScreen(
|
||||
) {
|
||||
val favorites by viewModel.favorites.collectAsState()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsState()
|
||||
val nowPlaying by viewModel.nowPlaying.collectAsState()
|
||||
val playingStationId by viewModel.playingStationId.collectAsState()
|
||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
Column(
|
||||
@@ -81,7 +84,7 @@ fun FavoritesScreen(
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
columns = GridCells.Fixed(if (com.radiola.ui.util.isLandscape()) 4 else 2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -93,6 +96,9 @@ fun FavoritesScreen(
|
||||
isFavorite = favoriteIds.contains(station.id),
|
||||
onClick = { onStationClick(station) },
|
||||
onFavoriteClick = { viewModel.toggleFavorite(station) },
|
||||
nowTrack = nowPlaying[station.id],
|
||||
isCurrent = station.id == playingStationId,
|
||||
isPlaying = isPlaying,
|
||||
modifier = Modifier.animateItemPlacement()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.radiola.ui.favorites
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.PushFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.SyncFavoritesUseCase
|
||||
import com.radiola.service.PlayerController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -20,9 +24,15 @@ class FavoritesViewModel @Inject constructor(
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val pushFavoriteUseCase: PushFavoriteUseCase,
|
||||
private val syncFavoritesUseCase: SyncFavoritesUseCase
|
||||
private val syncFavoritesUseCase: SyncFavoritesUseCase,
|
||||
private val nowPlayingRepository: NowPlayingRepository,
|
||||
private val playerController: PlayerController
|
||||
) : ViewModel() {
|
||||
|
||||
// Активная (играющая) станция — для подсветки карточки, как на экране всех станций.
|
||||
val playingStationId: StateFlow<Int?> = playerController.currentStationId
|
||||
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||
|
||||
val favorites: StateFlow<List<Station>> = favoritesRepository.getFavorites()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
@@ -30,10 +40,21 @@ class FavoritesViewModel @Inject constructor(
|
||||
.map { list -> list.map { it.id }.toSet() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||
|
||||
// Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
|
||||
val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
syncFavoritesUseCase()
|
||||
}
|
||||
// Периодическое обновление now-playing каждые 20 секунд.
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
delay(20_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
|
||||
@@ -5,19 +5,26 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Mic
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.ui.components.DeeplinkBottomSheet
|
||||
import com.radiola.ui.components.EmptyState
|
||||
@@ -32,10 +39,14 @@ fun HistoryScreen(
|
||||
viewModel: HistoryViewModel = hiltViewModel()
|
||||
) {
|
||||
val history by viewModel.history.collectAsState()
|
||||
val recognized by viewModel.recognized.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
var selectedTrack by remember { mutableStateOf<Track?>(null) }
|
||||
var tab by remember { mutableStateOf(0) }
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
val items = if (tab == 0) history else recognized
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -51,6 +62,30 @@ fun HistoryScreen(
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Переключатель вкладок: Треки эфира / Распознанные
|
||||
Row(
|
||||
modifier = Modifier.padding(bottom = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf("Треки эфира", "Распознанные").forEachIndexed { index, label ->
|
||||
val selected = tab == index
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (selected) colors.accent else colors.surface2)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { tab = index }
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = viewModel::onSearchQueryChange,
|
||||
@@ -59,7 +94,7 @@ fun HistoryScreen(
|
||||
)
|
||||
|
||||
Crossfade(
|
||||
targetState = history.isEmpty(),
|
||||
targetState = items.isEmpty(),
|
||||
label = "historyState"
|
||||
) { isEmpty ->
|
||||
if (isEmpty) {
|
||||
@@ -67,18 +102,26 @@ fun HistoryScreen(
|
||||
visible = true,
|
||||
enter = fadeIn() + slideInVertically()
|
||||
) {
|
||||
EmptyState(
|
||||
message = "История пуста",
|
||||
icon = Lucide.History,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
if (tab == 1) {
|
||||
EmptyState(
|
||||
message = "Пока ничего не распознано",
|
||||
icon = Lucide.Mic,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
EmptyState(
|
||||
message = "История пуста",
|
||||
icon = Lucide.History,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(history) { track ->
|
||||
items(items) { track ->
|
||||
TrackListItem(
|
||||
track = track,
|
||||
onClick = { selectedTrack = track }
|
||||
|
||||
@@ -3,37 +3,36 @@ package com.radiola.ui.history
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryViewModel @Inject constructor(
|
||||
private val trackHistoryRepository: TrackHistoryRepository
|
||||
private val trackHistoryRepository: TrackHistoryRepository,
|
||||
private val recognizedTrackRepository: RecognizedTrackRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val history: StateFlow<List<Track>> = combine(
|
||||
trackHistoryRepository.getHistory(),
|
||||
_searchQuery
|
||||
) { tracks, query ->
|
||||
if (query.isBlank()) tracks else tracks.filter {
|
||||
it.artist.contains(query, ignoreCase = true) ||
|
||||
it.song.contains(query, ignoreCase = true)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
private fun filtered(source: Flow<List<Track>>): StateFlow<List<Track>> =
|
||||
combine(source, _searchQuery) { tracks, query ->
|
||||
if (query.isBlank()) tracks else tracks.filter {
|
||||
it.artist.contains(query, ignoreCase = true) ||
|
||||
it.song.contains(query, ignoreCase = true)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
// Треки эфира (как игрались на станциях)
|
||||
val history: StateFlow<List<Track>> = filtered(trackHistoryRepository.getHistory())
|
||||
|
||||
// Распознанные через Shazam
|
||||
val recognized: StateFlow<List<Track>> = filtered(recognizedTrackRepository.getHistory())
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun removeTrack(track: Track) {
|
||||
viewModelScope.launch {
|
||||
trackHistoryRepository.removeTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt
Normal file
126
app/src/main/java/com/radiola/ui/lyrics/LyricsSheet.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
package com.radiola.ui.lyrics
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
/**
|
||||
* Содержимое шторки текста песни. Данные загружаются через LyricsViewModel → LRCLIB API.
|
||||
* Встраивается в ModalBottomSheet на стороне вызывающего экрана.
|
||||
*/
|
||||
@Composable
|
||||
fun LyricsSheet(
|
||||
artist: String,
|
||||
song: String,
|
||||
durationSec: Int? = null,
|
||||
viewModel: LyricsViewModel = hiltViewModel()
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(artist, song) {
|
||||
viewModel.load(artist, song, durationSec)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
// Заголовок
|
||||
Text(
|
||||
text = song,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = colors.textPrimary,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colors.textSecondary,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Состояния
|
||||
when (val s = state) {
|
||||
is LyricsState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
is LyricsState.Instrumental -> {
|
||||
Text(
|
||||
text = "Инструментальная композиция",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(vertical = 40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is LyricsState.Found -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 480.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
s.plain.lines().forEach { line ->
|
||||
Text(
|
||||
text = line.ifEmpty { " " },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.textPrimary,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LyricsState.NotFound -> {
|
||||
Text(
|
||||
text = "Текст не найден",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(vertical = 40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is LyricsState.Error -> {
|
||||
Text(
|
||||
text = "Не удалось загрузить текст",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(vertical = 40.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Атрибуция LRCLIB
|
||||
Text(
|
||||
text = "Тексты: LRCLIB",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt
Normal file
44
app/src/main/java/com/radiola/ui/lyrics/LyricsViewModel.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.radiola.ui.lyrics
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.repository.LyricsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface LyricsState {
|
||||
data object Loading : LyricsState
|
||||
data object Instrumental : LyricsState
|
||||
data class Found(val plain: String) : LyricsState
|
||||
data object NotFound : LyricsState
|
||||
data object Error : LyricsState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class LyricsViewModel @Inject constructor(
|
||||
private val repository: LyricsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<LyricsState>(LyricsState.Loading)
|
||||
val state: StateFlow<LyricsState> = _state
|
||||
|
||||
fun load(artist: String, song: String, durationSec: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
_state.value = LyricsState.Loading
|
||||
try {
|
||||
val result = repository.fetchLyrics(artist, song, durationSec)
|
||||
_state.value = when {
|
||||
result == null -> LyricsState.NotFound
|
||||
result.instrumental -> LyricsState.Instrumental
|
||||
!result.plain.isNullOrBlank() -> LyricsState.Found(result.plain)
|
||||
else -> LyricsState.NotFound
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_state.value = LyricsState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.radiola.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -12,40 +14,62 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
|
||||
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
|
||||
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
||||
private val navItems = listOf(
|
||||
NavDestinations.Stations,
|
||||
NavDestinations.Favorites,
|
||||
NavDestinations.History,
|
||||
NavDestinations.Charts,
|
||||
NavDestinations.Recordings,
|
||||
NavDestinations.Settings
|
||||
)
|
||||
|
||||
/** Переход на раздел с сохранением состояния (общий для нижнего бара и бокового рейла). */
|
||||
private fun NavController.navigateToTab(route: String, currentRoute: String?) {
|
||||
if (currentRoute != route) {
|
||||
navigate(route) {
|
||||
popUpTo(graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavBar(navController: NavController) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||
// Обращаемся к объектам напрямую: companion-список NavDestinations.items
|
||||
// при холодном старте может содержать null (порядок инициализации Kotlin).
|
||||
val items = listOf(
|
||||
NavDestinations.Stations,
|
||||
NavDestinations.Favorites,
|
||||
NavDestinations.History,
|
||||
NavDestinations.Recordings,
|
||||
NavDestinations.Settings
|
||||
)
|
||||
val items = navItems
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -65,21 +89,94 @@ fun BottomNavBar(navController: NavController) {
|
||||
label = destination.labelRes,
|
||||
icon = destination.icon,
|
||||
selected = selected,
|
||||
modifier = Modifier.weight(if (selected) 1.9f else 1f),
|
||||
onClick = {
|
||||
if (currentRoute != destination.route) {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { navController.navigateToTab(destination.route, currentRoute) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Боковой навигационный рейл для альбомной ориентации.
|
||||
* Вертикальная капсула с теми же иконками-вкладками, что и нижний бар.
|
||||
*/
|
||||
@Composable
|
||||
fun SideNavRail(navController: NavController) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.statusBarsPadding()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp)
|
||||
.width(64.dp)
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(colors.surface2)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(32.dp))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
navItems.forEach { destination ->
|
||||
VerticalPillTab(
|
||||
label = destination.labelRes,
|
||||
icon = destination.icon,
|
||||
selected = currentRoute == destination.route,
|
||||
onClick = { navController.navigateToTab(destination.route, currentRoute) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalPillTab(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val bg by animateColorAsState(
|
||||
targetValue = if (selected) colors.accent else androidx.compose.ui.graphics.Color.Transparent,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "railTabBg"
|
||||
)
|
||||
val content by animateColorAsState(
|
||||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "railTabFg"
|
||||
)
|
||||
val pop = remember { Animatable(1f) }
|
||||
LaunchedEffect(selected) {
|
||||
if (selected) {
|
||||
pop.snapTo(0.45f)
|
||||
pop.animateTo(1f, spring(dampingRatio = 0.36f, stiffness = 240f))
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
.background(bg)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = content,
|
||||
modifier = Modifier.size(22.dp).scale(pop.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PillTab(
|
||||
label: String,
|
||||
@@ -99,6 +196,14 @@ private fun PillTab(
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "tabFg"
|
||||
)
|
||||
// Упругий «поп» иконки при выборе вкладки — маленькая приятная деталь.
|
||||
val pop = remember { Animatable(1f) }
|
||||
LaunchedEffect(selected) {
|
||||
if (selected) {
|
||||
pop.snapTo(0.45f)
|
||||
pop.animateTo(1f, spring(dampingRatio = 0.36f, stiffness = 240f))
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -113,26 +218,12 @@ private fun PillTab(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Только иконки — подписи не помещались для 6 разделов.
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = content,
|
||||
modifier = Modifier.height(18.dp).width(18.dp)
|
||||
modifier = Modifier.height(22.dp).width(22.dp).scale(pop.value)
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = selected,
|
||||
enter = fadeIn(tween(Motion.Medium)) + expandHorizontally(tween(Motion.Medium)),
|
||||
exit = fadeOut(tween(Motion.Fast)) + shrinkHorizontally(tween(Motion.Fast))
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label.uppercase(),
|
||||
color = content,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.radiola.ui.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Mic
|
||||
import com.composables.icons.lucide.Radio
|
||||
import com.composables.icons.lucide.Settings
|
||||
import com.composables.icons.lucide.TrendingUp
|
||||
|
||||
sealed class NavDestinations(
|
||||
val route: String,
|
||||
@@ -15,13 +17,16 @@ sealed class NavDestinations(
|
||||
val showInBottomBar: Boolean = true
|
||||
) {
|
||||
data object Stations : NavDestinations("stations", "Радио", Lucide.Radio)
|
||||
data object Charts : NavDestinations("charts", "Чарты", Lucide.TrendingUp)
|
||||
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)
|
||||
data object Alarms : NavDestinations("alarms", "Будильник", Lucide.AlarmClock, showInBottomBar = false)
|
||||
data object Equalizer : NavDestinations("equalizer", "Эквалайзер", Lucide.Settings, showInBottomBar = false)
|
||||
|
||||
companion object {
|
||||
val items = listOf(Stations, Favorites, History, Recordings, Settings)
|
||||
val items = listOf(Stations, Charts, Favorites, History, Recordings, Settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -32,26 +34,34 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import com.composables.icons.lucide.Check
|
||||
import com.composables.icons.lucide.FileText
|
||||
import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Mic
|
||||
import com.composables.icons.lucide.MicOff
|
||||
import com.composables.icons.lucide.Moon
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.Pause
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.Radio
|
||||
import com.composables.icons.lucide.SkipBack
|
||||
import com.composables.icons.lucide.SkipForward
|
||||
import com.composables.icons.lucide.SlidersHorizontal
|
||||
import com.radiola.deeplink.DeeplinkNavigator
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.ui.lyrics.LyricsSheet
|
||||
import com.radiola.ui.theme.LiveEqualizer
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import com.radiola.ui.theme.pressScale
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerBottomSheet(
|
||||
station: Station?,
|
||||
@@ -69,44 +79,81 @@ fun PlayerBottomSheet(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val recognizing by viewModel.recognizing.collectAsState()
|
||||
val colors = RadiolaTheme.colors
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.bgBase)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Метка «В ЭФИРЕ»
|
||||
Text(
|
||||
text = "В ЭФИРЕ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.accent,
|
||||
letterSpacing = 2.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.recognizeEvent.collect { msg ->
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
var showLyrics by remember { mutableStateOf(false) }
|
||||
var showQuality by remember { mutableStateOf(false) }
|
||||
var showSleep by remember { mutableStateOf(false) }
|
||||
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
|
||||
val currentQuality by viewModel.currentQuality.collectAsState()
|
||||
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
||||
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||||
|
||||
val landscape = com.radiola.ui.util.isLandscape()
|
||||
|
||||
// ── Секции плеера как лямбды: переиспользуются в портретной (колонка)
|
||||
// и альбомной (две панели) раскладках. ──
|
||||
|
||||
val labelSection: @Composable () -> Unit = {
|
||||
// Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты)
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "В ЭФИРЕ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.accent,
|
||||
letterSpacing = 2.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
val qualities = station?.qualities.orEmpty()
|
||||
if (qualities.size >= 2) {
|
||||
QualityChip(
|
||||
label = "${(currentQuality?.bitrate ?: qualities.first().bitrate)}k",
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showQuality = true
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nameSection: @Composable () -> Unit = {
|
||||
// Название радиостанции — под меткой, над обложкой
|
||||
Text(
|
||||
text = station?.name ?: "",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.basicMarquee()
|
||||
)
|
||||
}
|
||||
|
||||
val coverSection: @Composable (Dp) -> Unit = { coverSize ->
|
||||
// Обложка станции/трека
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.size(coverSize)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(colors.surface2),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val coverModel = track?.coverUrl ?: station?.coverUrl
|
||||
if (!coverModel.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = com.radiola.ui.components.crossfadeModel(coverModel),
|
||||
contentDescription = station?.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
com.radiola.ui.components.FlipCover(
|
||||
model = coverModel,
|
||||
contentDescription = station?.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Radio,
|
||||
contentDescription = null,
|
||||
@@ -115,8 +162,9 @@ fun PlayerBottomSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
|
||||
val trackInfoSection: @Composable () -> Unit = {
|
||||
// Название трека и исполнитель с Crossfade при смене
|
||||
Crossfade(
|
||||
targetState = track?.song to track?.artist,
|
||||
@@ -143,21 +191,83 @@ fun PlayerBottomSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
|
||||
LiveEqualizer(
|
||||
// Кнопка распознавания (Shazam) — только для музыкальных станций без РЕАЛЬНЫХ
|
||||
// метаданных эфира. «Безымянные» станции часто шлют ICY-строку (слоган/название)
|
||||
// без разделителя → parseIcyTitle делает трек с ПУСТЫМ исполнителем; такой трек
|
||||
// и есть «нет названия» → кнопку показываем. Настоящий «Исполнитель — Трек»
|
||||
// (artist и song заполнены) → кнопка скрыта.
|
||||
val recognizeSection: @Composable () -> Unit = {
|
||||
val noRealTrack = track == null ||
|
||||
track.artist.isBlank() ||
|
||||
track.song.isBlank() ||
|
||||
track.song == station?.name
|
||||
val show = station != null &&
|
||||
noRealTrack &&
|
||||
com.radiola.domain.model.MusicGenres.isMusicStation(station.genre)
|
||||
if (show) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(colors.accent.copy(alpha = 0.15f))
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
enabled = !recognizing
|
||||
) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.recognizeCurrentTrack()
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (recognizing) {
|
||||
CircularProgressIndicator(
|
||||
color = colors.accent,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Lucide.Mic,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (recognizing) "Распознаём…" else "Распознать трек",
|
||||
color = colors.accent,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val visualizerSection: @Composable () -> Unit = {
|
||||
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
|
||||
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
|
||||
VisualizerHost(
|
||||
viewModel = viewModel,
|
||||
vizStyle = vizStyle,
|
||||
playing = isPlaying,
|
||||
color = colors.accent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
playing = isPlaying,
|
||||
color = colors.accent
|
||||
.height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp)
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
val controlsSection: @Composable () -> Unit = {
|
||||
// Управление воспроизведением
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Кнопка избранного
|
||||
@@ -166,15 +276,15 @@ fun PlayerBottomSheet(
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "heartTint"
|
||||
)
|
||||
PlayerIconBtn(size = 44.dp) {
|
||||
PlayerIconBtn(size = 48.dp) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onToggleFavorite()
|
||||
},
|
||||
modifier = Modifier.size(44.dp)
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(22.dp))
|
||||
Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,8 +336,8 @@ fun PlayerBottomSheet(
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "recordTint"
|
||||
)
|
||||
PlayerIconBtn(size = 44.dp) {
|
||||
IconButton(onClick = onToggleRecording, modifier = Modifier.size(44.dp)) {
|
||||
PlayerIconBtn(size = 48.dp) {
|
||||
IconButton(onClick = onToggleRecording, modifier = Modifier.size(48.dp)) {
|
||||
Crossfade(
|
||||
targetState = isRecording,
|
||||
animationSpec = tween(Motion.Fast),
|
||||
@@ -237,14 +347,15 @@ fun PlayerBottomSheet(
|
||||
imageVector = if (recording) Lucide.MicOff else Lucide.Mic,
|
||||
contentDescription = if (recording) "Остановить запись" else "Запись",
|
||||
tint = recordTint,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
val servicesSection: @Composable () -> Unit = {
|
||||
// Ряд кнопок музыкальных сервисов
|
||||
if (enabledServices.isNotEmpty()) {
|
||||
LazyRow(
|
||||
@@ -266,6 +377,434 @@ fun PlayerBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val lyricsSection: @Composable () -> Unit = {
|
||||
// Кнопка «Текст песни» — активна только когда играет трек.
|
||||
// Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден.
|
||||
if (track != null) {
|
||||
val lyricsInteraction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(colors.surface2)
|
||||
.pressScale(interactionSource = lyricsInteraction)
|
||||
.clickable(interactionSource = lyricsInteraction, indication = null) {
|
||||
showLyrics = true
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.FileText,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Текст песни",
|
||||
color = colors.accent,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка таймера сна. Активен → подсветка акцентом + оставшееся время MM:SS.
|
||||
val sleepSection: @Composable () -> Unit = {
|
||||
val active = sleepRemainingMs != null
|
||||
val sleepInteraction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (active) colors.accent.copy(alpha = 0.15f) else colors.surface2)
|
||||
.pressScale(interactionSource = sleepInteraction)
|
||||
.clickable(interactionSource = sleepInteraction, indication = null) {
|
||||
showSleep = true
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Moon,
|
||||
contentDescription = null,
|
||||
tint = if (active) colors.accent else colors.textSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = sleepRemainingMs?.let { "Сон · ${formatSleep(it)}" } ?: "Таймер сна",
|
||||
color = if (active) colors.accent else colors.textSecondary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (landscape) {
|
||||
// Альбом: слева обложка с названием станции, справа — трек, эквалайзер,
|
||||
// управление и сервисы (правая панель скроллится на низких экранах).
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.bgBase)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(0.42f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
labelSection()
|
||||
Spacer(Modifier.height(6.dp))
|
||||
nameSection()
|
||||
Spacer(Modifier.height(14.dp))
|
||||
coverSection(170.dp)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(0.58f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
trackInfoSection()
|
||||
recognizeSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
visualizerSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
controlsSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
servicesSection()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
sleepSection()
|
||||
if (track != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
lyricsSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.bgBase)
|
||||
.navigationBarsPadding()
|
||||
// Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi)
|
||||
// низ плеера (кнопка «Текст песни») не обрезался шторкой.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
labelSection()
|
||||
Spacer(Modifier.height(6.dp))
|
||||
nameSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
coverSection(190.dp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
trackInfoSection()
|
||||
recognizeSection()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
visualizerSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
controlsSection()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
servicesSection()
|
||||
if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp))
|
||||
sleepSection()
|
||||
Spacer(Modifier.height(10.dp))
|
||||
lyricsSection()
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка выбора качества
|
||||
if (showQuality && station != null) {
|
||||
val qualities = station.qualities
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showQuality = false },
|
||||
containerColor = colors.bgBase,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Качество звука",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
qualities.forEach { q ->
|
||||
QualityRow(
|
||||
quality = q,
|
||||
selected = currentQuality?.bitrate == q.bitrate,
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.selectQuality(q)
|
||||
showQuality = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка текста песни
|
||||
if (showLyrics && track != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showLyrics = false },
|
||||
containerColor = colors.bgBase,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
LyricsSheet(
|
||||
artist = track.artist,
|
||||
song = track.song
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Шторка таймера сна
|
||||
if (showSleep) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showSleep = false },
|
||||
containerColor = colors.bgBase,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Таймер сна",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
// Выбор звука для сна: радио плавно перетечёт в выбранный шум.
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
item {
|
||||
SoundChip("Без звука", selectedSound == null) { selectedSound = null }
|
||||
}
|
||||
items(com.radiola.service.SleepSound.entries) { snd ->
|
||||
SoundChip(snd.title, selectedSound == snd) { selectedSound = snd }
|
||||
}
|
||||
}
|
||||
// Если активен — показываем остаток и кнопку отмены
|
||||
if (sleepRemainingMs != null) {
|
||||
SleepRow(
|
||||
label = "Осталось ${formatSleep(sleepRemainingMs!!)}",
|
||||
selected = true,
|
||||
onClick = {
|
||||
viewModel.cancelSleepTimer()
|
||||
showSleep = false
|
||||
},
|
||||
trailing = "Выключить"
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
listOf(15, 30, 45, 60, 90, 120).forEach { min ->
|
||||
SleepRow(
|
||||
label = "$min минут",
|
||||
selected = false,
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.startSleepTimer(min, selectedSound)
|
||||
showSleep = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка выбора интервала таймера сна. */
|
||||
@Composable
|
||||
private fun SleepRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
trailing: String? = null
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Moon,
|
||||
contentDescription = null,
|
||||
tint = if (selected) colors.accent else colors.textSecondary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (selected) colors.accent else colors.textPrimary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (trailing != null) {
|
||||
Text(
|
||||
text = trailing,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colors.live,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaf-обёртка эквалайзера: сама собирает спектр (обновляется ~45/с) и включает
|
||||
* расчёт FFT только пока скомпонована (открыт плеер) — это и изолирует частые
|
||||
* рекомпозиции от остального плеера, и гасит FFT в фоне (батарея).
|
||||
*/
|
||||
@Composable
|
||||
private fun VisualizerHost(
|
||||
viewModel: PlayerViewModel,
|
||||
vizStyle: String,
|
||||
playing: Boolean,
|
||||
color: Color,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val spectrum by viewModel.spectrum.collectAsState()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.setSpectrumActive(true)
|
||||
onDispose { viewModel.setSpectrumActive(false) }
|
||||
}
|
||||
com.radiola.ui.components.Visualizer(
|
||||
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
||||
levels = spectrum,
|
||||
playing = playing,
|
||||
color = color,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
/** Чип выбора звука для сна. */
|
||||
@Composable
|
||||
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.bgBase else colors.textSecondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (selected) colors.accent else colors.surface2)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
|
||||
private fun formatSleep(ms: Long): String {
|
||||
val total = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
|
||||
/** Компактный чип текущего качества звука. */
|
||||
@Composable
|
||||
private fun QualityChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(colors.surface2)
|
||||
.pressScale(interactionSource = interaction)
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.SlidersHorizontal,
|
||||
contentDescription = "Качество",
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(13.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка выбора одного качества в шторке. */
|
||||
@Composable
|
||||
private fun QualityRow(
|
||||
quality: com.radiola.domain.model.StreamQuality,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = quality.tierLabel,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (selected) colors.accent else colors.textPrimary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
Text(
|
||||
text = "${quality.bitrate} kbps · ${quality.type.uppercase()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Lucide.Check,
|
||||
contentDescription = "Выбрано",
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Обёртка для иконок-кнопок управления — прозрачный фон. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.StreamQuality
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.SettingsRepository
|
||||
import com.radiola.domain.repository.StationRepository
|
||||
@@ -12,6 +13,9 @@ 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.RecognizeResult
|
||||
import com.radiola.domain.repository.RecognizedTrackRepository
|
||||
import com.radiola.domain.repository.ShazamRepository
|
||||
import com.radiola.domain.repository.TrackHistoryRepository
|
||||
import com.radiola.domain.usecase.ToggleFavoriteUseCase
|
||||
import com.radiola.domain.usecase.auth.PushHistoryUseCase
|
||||
@@ -33,13 +37,21 @@ class PlayerViewModel @Inject constructor(
|
||||
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val trackHistoryRepository: TrackHistoryRepository,
|
||||
private val recognizedTrackRepository: RecognizedTrackRepository,
|
||||
private val shazamRepository: ShazamRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val recordingRepository: RecordingRepository,
|
||||
private val pushHistoryUseCase: PushHistoryUseCase
|
||||
private val pushHistoryUseCase: PushHistoryUseCase,
|
||||
private val loveStreamResolver: com.radiola.data.remote.LoveStreamResolver,
|
||||
private val recordingPlaybackController: com.radiola.service.RecordingPlaybackController
|
||||
) : ViewModel() {
|
||||
|
||||
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
|
||||
val spectrum: StateFlow<FloatArray> = playerController.spectrum
|
||||
|
||||
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
|
||||
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), "bars_center")
|
||||
|
||||
private val _currentStation = MutableStateFlow<Station?>(null)
|
||||
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()
|
||||
@@ -47,6 +59,17 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _currentTrack = MutableStateFlow<Track?>(null)
|
||||
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow()
|
||||
|
||||
// Распознавание трека (Shazam) — индикатор и одноразовые сообщения для UI.
|
||||
private val _recognizing = MutableStateFlow(false)
|
||||
val recognizing: StateFlow<Boolean> = _recognizing.asStateFlow()
|
||||
|
||||
private val _recognizeEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val recognizeEvent: SharedFlow<String> = _recognizeEvent.asSharedFlow()
|
||||
|
||||
// Ключ трека, добавленного через распознавание — его НЕ дублируем в историю
|
||||
// «эфирных» треков (он идёт в отдельную историю распознанных).
|
||||
private var recognizedKey: String? = null
|
||||
|
||||
private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
|
||||
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
|
||||
|
||||
@@ -56,8 +79,25 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _playlist = MutableStateFlow<List<Station>>(emptyList())
|
||||
val playlist: StateFlow<List<Station>> = _playlist.asStateFlow()
|
||||
|
||||
// Выбранное качество текущей станции (битрейт). null — у станции нет вариантов.
|
||||
private val _currentQuality = MutableStateFlow<StreamQuality?>(null)
|
||||
val currentQuality: StateFlow<StreamQuality?> = _currentQuality.asStateFlow()
|
||||
|
||||
// Предпочитаемый битрейт пользователя (0 = авто/по умолчанию станции).
|
||||
private var preferredBitrate: Int = 0
|
||||
|
||||
val isRecording: StateFlow<Boolean> = recordingRepository.isRecording
|
||||
|
||||
// Таймер сна: оставшееся время в мс (null = выключен).
|
||||
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
|
||||
|
||||
fun startSleepTimer(minutes: Int, sound: com.radiola.service.SleepSound? = null) =
|
||||
playerController.startSleepTimer(minutes * 60_000L, sound)
|
||||
fun cancelSleepTimer() = playerController.cancelSleepTimer()
|
||||
|
||||
// Спектр (FFT) считаем только пока открыт плеер — экономия батареи в фоне.
|
||||
fun setSpectrumActive(active: Boolean) = playerController.setSpectrumActive(active)
|
||||
|
||||
private var nowPlayingJob: Job? = null
|
||||
|
||||
init {
|
||||
@@ -71,29 +111,53 @@ class PlayerViewModel @Inject constructor(
|
||||
_enabledServices.value = DeeplinkService.entries.filter { it.serviceId in ids }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepository.getPreferredBitrate().collect { preferredBitrate = it }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_currentTrack
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { track ->
|
||||
// Распознанный трек уже в истории распознанных — не дублируем в эфирную.
|
||||
if (trackKey(track) == recognizedKey) return@collect
|
||||
trackHistoryRepository.addTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(station: Station, playlist: List<Station>? = null) {
|
||||
// Глушим плеер записи, если он играл — иначе два ExoPlayer'а конфликтуют
|
||||
// (радио не стартует, запись зависает без управления).
|
||||
recordingPlaybackController.stop()
|
||||
_currentStation.value = station
|
||||
_currentTrack.value = null
|
||||
recognizedKey = null
|
||||
_playlist.value = playlist ?: _stations.value
|
||||
playerController.play(station.streamUrl, station.prefix, station.name)
|
||||
// Выбираем стартовое качество: предпочтение пользователя → совпадение с
|
||||
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
|
||||
val quality = pickInitialQuality(station)
|
||||
_currentQuality.value = quality
|
||||
val streamUrl = quality?.url ?: station.streamUrl
|
||||
// Love Radio: подставляем сессионный UID (иначе поток отдаёт заглушку).
|
||||
// Для остальных resolve вернёт URL как есть.
|
||||
viewModelScope.launch {
|
||||
val url = loveStreamResolver.resolve(streamUrl)
|
||||
playerController.play(url, station.prefix, station.name, station.id)
|
||||
}
|
||||
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
||||
nowPlayingJob?.cancel()
|
||||
nowPlayingJob = viewModelScope.launch {
|
||||
// Polling loop for Record API now playing
|
||||
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
|
||||
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
|
||||
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
|
||||
launch {
|
||||
while (true) {
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
delay(10_000)
|
||||
playerController.isPlaying.collectLatest { playing ->
|
||||
if (!playing) return@collectLatest
|
||||
while (true) {
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect now playing for this station (API has priority: covers + accurate metadata)
|
||||
@@ -103,6 +167,10 @@ class PlayerViewModel @Inject constructor(
|
||||
.collect { track ->
|
||||
if (track != null) {
|
||||
_currentTrack.value = track
|
||||
// Нет обложки — обогащаем приоритетно (играет прямо сейчас).
|
||||
if (track.coverUrl.isNullOrBlank()) {
|
||||
nowPlayingRepository.enrichCoverNow(track)
|
||||
}
|
||||
playerController.updateMetadata(
|
||||
track.song,
|
||||
track.artist,
|
||||
@@ -136,6 +204,28 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Стартовое качество станции с учётом предпочтения пользователя. */
|
||||
private fun pickInitialQuality(station: Station): StreamQuality? {
|
||||
val list = station.qualities
|
||||
if (list.size < 2) return null
|
||||
return list.firstOrNull { it.bitrate == preferredBitrate }
|
||||
?: list.firstOrNull { it.url == station.streamUrl }
|
||||
?: list.first()
|
||||
}
|
||||
|
||||
/** Переключить качество текущей станции на лету (без сброса now-playing). */
|
||||
fun selectQuality(quality: StreamQuality) {
|
||||
val station = _currentStation.value ?: return
|
||||
if (_currentQuality.value?.bitrate == quality.bitrate) return
|
||||
_currentQuality.value = quality
|
||||
preferredBitrate = quality.bitrate
|
||||
viewModelScope.launch { settingsRepository.setPreferredBitrate(quality.bitrate) }
|
||||
viewModelScope.launch {
|
||||
val url = loveStreamResolver.resolve(quality.url)
|
||||
playerController.changeStream(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIcyTitle(title: String?): Track? {
|
||||
if (title.isNullOrBlank()) return null
|
||||
val separators = listOf(" - ", " — ", " – ")
|
||||
@@ -193,6 +283,32 @@ class PlayerViewModel @Inject constructor(
|
||||
return searchTrackInServiceUseCase(track, service)
|
||||
}
|
||||
|
||||
/** Распознать играющий сейчас трек через Shazam (бэкенд тянет аудио из потока). */
|
||||
fun recognizeCurrentTrack() {
|
||||
val station = _currentStation.value ?: return
|
||||
if (_recognizing.value) return
|
||||
_recognizing.value = true
|
||||
viewModelScope.launch {
|
||||
when (val r = shazamRepository.recognize(station.id, station.name)) {
|
||||
is RecognizeResult.Found -> {
|
||||
recognizedKey = trackKey(r.track)
|
||||
_currentTrack.value = r.track
|
||||
recognizedTrackRepository.addTrack(r.track)
|
||||
playerController.updateMetadata(
|
||||
r.track.song, r.track.artist, r.track.coverUrl ?: "", station.name
|
||||
)
|
||||
_recognizeEvent.emit("Распознано: ${r.track.artist} — ${r.track.song}")
|
||||
}
|
||||
is RecognizeResult.NotFound -> _recognizeEvent.emit("Не удалось распознать трек")
|
||||
is RecognizeResult.Error -> _recognizeEvent.emit(r.message)
|
||||
}
|
||||
_recognizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackKey(t: Track): String =
|
||||
(t.artist.trim() + "|" + t.song.trim()).lowercase()
|
||||
|
||||
fun toggleFavorite(station: Station) {
|
||||
viewModelScope.launch {
|
||||
toggleFavoriteUseCase(station)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.radiola.ui.recordings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Pause
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.RotateCcw
|
||||
import com.composables.icons.lucide.RotateCw
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import com.radiola.ui.theme.pressScale
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Контент нижнего листа (bottom sheet) для воспроизведения записи эфира.
|
||||
* Оборачивать в [ModalBottomSheet] на стороне вызывающего.
|
||||
*/
|
||||
@Composable
|
||||
fun RecordingPlayerSheet(
|
||||
recording: Recording,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: RecordingPlayerViewModel
|
||||
) {
|
||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||
val positionMs by viewModel.positionMs.collectAsState()
|
||||
val durationMs by viewModel.durationMs.collectAsState()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val colors = RadiolaTheme.colors
|
||||
val dateFormat = remember { SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) }
|
||||
|
||||
// Запуск воспроизведения при открытии листа
|
||||
LaunchedEffect(recording) {
|
||||
viewModel.play(recording)
|
||||
}
|
||||
|
||||
val landscape = com.radiola.ui.util.isLandscape()
|
||||
val effectiveDuration = durationMs.coerceAtLeast(recording.duration ?: 1L).coerceAtLeast(1L)
|
||||
|
||||
val headerSection: @Composable () -> Unit = {
|
||||
// Метка «ЗАПИСЬ ЭФИРА»
|
||||
Text(
|
||||
text = "ЗАПИСЬ ЭФИРА",
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
letterSpacing = 2.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = colors.accent
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
// Название станции
|
||||
Text(
|
||||
text = recording.stationName,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Трек (если есть) + дата
|
||||
val meta = buildString {
|
||||
if (!recording.trackName.isNullOrBlank()) {
|
||||
append(recording.trackName)
|
||||
append(" · ")
|
||||
}
|
||||
append(dateFormat.format(Date(recording.startTime)))
|
||||
}
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
val seekSection: @Composable () -> Unit = {
|
||||
// Seekbar
|
||||
Slider(
|
||||
value = positionMs.toFloat(),
|
||||
onValueChange = { viewModel.seekTo(it.toLong()) },
|
||||
valueRange = 0f..effectiveDuration.toFloat(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = colors.accent,
|
||||
activeTrackColor = colors.accent,
|
||||
inactiveTrackColor = colors.surface2
|
||||
)
|
||||
)
|
||||
// Время: текущее и общее
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = formatMs(positionMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted
|
||||
)
|
||||
Text(
|
||||
text = formatMs(effectiveDuration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.textMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val controlsSection: @Composable () -> Unit = {
|
||||
// Ряд управления: rewind15, play/pause, forward15
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||
) {
|
||||
// Перемотка назад на 15 секунд
|
||||
IconButton(
|
||||
onClick = { viewModel.rewind15() },
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.RotateCcw,
|
||||
contentDescription = "Назад 15 сек",
|
||||
tint = colors.textSecondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопка play/pause
|
||||
val playInteraction = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.pressScale(interactionSource = playInteraction),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.togglePlayPause()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.accent,
|
||||
contentColor = colors.bgBase
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
interactionSource = playInteraction
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isPlaying,
|
||||
animationSpec = tween(Motion.Fast),
|
||||
label = "playPauseCrossfade"
|
||||
) { playing ->
|
||||
Icon(
|
||||
imageVector = if (playing) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (playing) "Пауза" else "Воспроизвести",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Перемотка вперёд на 15 секунд
|
||||
IconButton(
|
||||
onClick = { viewModel.forward15() },
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.RotateCw,
|
||||
contentDescription = "Вперёд 15 сек",
|
||||
tint = colors.textSecondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Заголовок + строки списка треков. modifier — чтобы в альбоме правая
|
||||
// панель скроллилась отдельно.
|
||||
val markersSection: @Composable (Modifier) -> Unit = { listModifier ->
|
||||
if (recording.markers.isNotEmpty()) {
|
||||
Column(modifier = listModifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Треки в записи",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "${recording.markers.size}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textMuted
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Индекс текущего трека: последняя метка, до которой уже дошло время
|
||||
val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs }
|
||||
recording.markers.forEachIndexed { index, marker ->
|
||||
MarkerRow(
|
||||
timecode = formatMs(marker.offsetMs),
|
||||
title = marker.title,
|
||||
active = index == activeIndex,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.seekTo(marker.offsetMs)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (landscape) {
|
||||
// Альбом: слева управление, справа — прокручиваемый список треков.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(0.5f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
headerSection()
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
seekSection()
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
controlsSection()
|
||||
}
|
||||
if (recording.markers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
markersSection(
|
||||
Modifier
|
||||
.weight(0.5f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Отступ под системную навигацию — иначе список треков уходит под кнопки
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
headerSection()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
seekSection()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
controlsSection()
|
||||
if (recording.markers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
markersSection(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка трека в записи: тайм-код + название, тап → переход. */
|
||||
@Composable
|
||||
private fun MarkerRow(
|
||||
timecode: String,
|
||||
title: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (active) colors.surface2 else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = timecode,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (active) colors.accent else colors.textMuted,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = title.ifBlank { "—" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (active) colors.accent else colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Форматирует миллисекунды в строку mm:ss. */
|
||||
private fun formatMs(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.radiola.ui.recordings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.radiola.domain.model.Recording
|
||||
import com.radiola.service.RecordingPlaybackController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel экрана воспроизведения записи. Проксирует состояние и команды
|
||||
* к [RecordingPlaybackController].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RecordingPlayerViewModel @Inject constructor(
|
||||
private val controller: RecordingPlaybackController
|
||||
) : ViewModel() {
|
||||
|
||||
val current: StateFlow<Recording?> = controller.current
|
||||
val isPlaying: StateFlow<Boolean> = controller.isPlaying
|
||||
val positionMs: StateFlow<Long> = controller.positionMs
|
||||
val durationMs: StateFlow<Long> = controller.durationMs
|
||||
|
||||
fun play(recording: Recording) = controller.play(recording)
|
||||
|
||||
fun togglePlayPause() = controller.togglePlayPause()
|
||||
|
||||
fun seekTo(ms: Long) = controller.seekTo(ms)
|
||||
|
||||
/** Перемотка назад на 15 секунд. */
|
||||
fun rewind15() = controller.seekBy(-15_000L)
|
||||
|
||||
/** Перемотка вперёд на 15 секунд. */
|
||||
fun forward15() = controller.seekBy(15_000L)
|
||||
|
||||
/** Закрыть плеер и остановить воспроизведение. */
|
||||
fun close() = controller.stop()
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -40,15 +39,22 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecordingsScreen(
|
||||
viewModel: RecordingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val recordings by viewModel.recordings.collectAsState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = RadiolaTheme.colors
|
||||
var playing by remember { mutableStateOf<Recording?>(null) }
|
||||
// Плеер записи — singleton-контроллер; держим его VM здесь, чтобы корректно
|
||||
// ОСТАНОВИТЬ воспроизведение при закрытии шторки и уходе с экрана (иначе
|
||||
// аудио продолжает играть без UI и конфликтует с радио).
|
||||
val recPlayerVm: RecordingPlayerViewModel = hiltViewModel()
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { recPlayerVm.close() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -119,21 +125,7 @@ fun RecordingsScreen(
|
||||
items(recordings, key = { it.id }) { recording ->
|
||||
RecordingItem(
|
||||
recording = recording,
|
||||
onPlay = {
|
||||
// TODO: воспроизвести запись через 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)
|
||||
},
|
||||
onPlay = { playing = recording },
|
||||
onDelete = { viewModel.deleteRecording(recording.id) },
|
||||
modifier = Modifier.animateItemPlacement()
|
||||
)
|
||||
@@ -142,6 +134,23 @@ fun RecordingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Встроенный плеер в нижнем листе. skipPartiallyExpanded — как у радио-плеера,
|
||||
// иначе в partial-режиме navigationBarsPadding не применяется и список треков
|
||||
// налезает на системную навигацию.
|
||||
playing?.let { rec ->
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { recPlayerVm.close(); playing = null },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = colors.bgBase
|
||||
) {
|
||||
RecordingPlayerSheet(
|
||||
recording = rec,
|
||||
onDismiss = { recPlayerVm.close(); playing = null },
|
||||
viewModel = recPlayerVm
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,7 +5,9 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -24,31 +26,38 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.composables.icons.lucide.AlarmClock
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.SlidersHorizontal
|
||||
import com.composables.icons.lucide.User
|
||||
import com.radiola.domain.model.DeeplinkService
|
||||
import com.radiola.domain.model.StationTestStatus
|
||||
import com.radiola.ui.theme.Motion
|
||||
import com.radiola.ui.theme.RadiolaTheme
|
||||
import com.radiola.ui.theme.ThemePalette
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToAuth: () -> Unit,
|
||||
onNavigateToAlarms: () -> Unit = {},
|
||||
onNavigateToEqualizer: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val sleepTimer by viewModel.sleepTimerMinutes.collectAsState()
|
||||
val enabledServices by viewModel.enabledServices.collectAsState()
|
||||
val equalizerPreset by viewModel.equalizerPreset.collectAsState()
|
||||
val visualizerStyle by viewModel.visualizerStyle.collectAsState()
|
||||
val themePalette by viewModel.themePalette.collectAsState()
|
||||
val isRecordingEnabled by viewModel.isRecordingEnabled.collectAsState()
|
||||
val preferredBitrate by viewModel.preferredBitrate.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) }
|
||||
val colors = RadiolaTheme.colors
|
||||
|
||||
@@ -71,6 +80,26 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// --- Тема оформления ---
|
||||
item {
|
||||
SectionLabel("ТЕМА ОФОРМЛЕНИЯ")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemePalette.entries.forEach { palette ->
|
||||
ThemeSwatch(
|
||||
palette = palette,
|
||||
selected = themePalette == palette.id,
|
||||
onClick = { viewModel.setThemePalette(palette.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Профиль ---
|
||||
item {
|
||||
SectionLabel("ПРОФИЛЬ")
|
||||
@@ -143,6 +172,48 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Будильник ---
|
||||
item {
|
||||
SectionLabel("БУДИЛЬНИК")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.clickable { onNavigateToAlarms() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
Lucide.AlarmClock,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Будильники",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Просыпайтесь под любимое радио",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Lucide.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Таймер сна ---
|
||||
item {
|
||||
SectionLabel("ТАЙМЕР СНА")
|
||||
@@ -186,10 +257,11 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Эквалайзер ---
|
||||
// --- Качество звука по умолчанию ---
|
||||
item {
|
||||
SectionLabel("ЭКВАЛАЙЗЕР")
|
||||
SectionLabel("КАЧЕСТВО ЗВУКА")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val options = listOf(0 to "Авто", 64 to "Эконом", 128 to "Стандарт", 320 to "Высокое")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -199,36 +271,130 @@ fun SettingsScreen(
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
presets.forEach { preset ->
|
||||
val selected = equalizerPreset == preset
|
||||
options.forEach { (bitrate, label) ->
|
||||
val selected = preferredBitrate == bitrate
|
||||
val bgColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.accent else colors.surface2,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "eqSegment"
|
||||
label = "qSegment"
|
||||
)
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = if (selected) colors.bgBase else colors.textSecondary,
|
||||
animationSpec = tween(Motion.Medium),
|
||||
label = "eqText"
|
||||
label = "qText"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable { viewModel.setEqualizerPreset(preset) }
|
||||
.padding(vertical = 8.dp),
|
||||
.clickable { viewModel.setPreferredBitrate(bitrate) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = preset,
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Применяется к станциям с несколькими потоками. «Авто» — выбор станции.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textMuted,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// --- Эквалайзер (отдельный детальный экран) ---
|
||||
item {
|
||||
SectionLabel("ЗВУК")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(1.dp, colors.border, RoundedCornerShape(16.dp))
|
||||
.clickable { onNavigateToEqualizer() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
Lucide.SlidersHorizontal,
|
||||
contentDescription = null,
|
||||
tint = colors.accent,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Эквалайзер",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Полосы, пресеты, бас, объём, громкость",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = colors.textSecondary
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Lucide.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = colors.textMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Стиль визуализации воспроизведения ---
|
||||
item {
|
||||
SectionLabel("АНИМАЦИЯ ВОСПРОИЗВЕДЕНИЯ")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
com.radiola.ui.components.VisualizerStyle.entries.chunked(2).forEach { rowStyles ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
rowStyles.forEach { style ->
|
||||
val selected = visualizerStyle == style.key
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(colors.surface)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) colors.accent else colors.border,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clickable { viewModel.setVisualizerStyle(style.key) }
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
com.radiola.ui.components.Visualizer(
|
||||
style = style,
|
||||
levels = null,
|
||||
playing = true,
|
||||
color = colors.accent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
)
|
||||
Text(
|
||||
text = style.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selected) colors.accent else colors.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Музыкальные сервисы ---
|
||||
@@ -449,6 +615,61 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Превью цветовой темы: квадрат с фоном палитры, акцентным кружком и брендовым
|
||||
* градиентом снизу. Выбранная — с акцентной рамкой и жирной подписью.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeSwatch(
|
||||
palette: ThemePalette,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val p = palette.colors
|
||||
val outer = RadiolaTheme.colors
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(p.bgBase)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) outer.accent else outer.border,
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(26.dp)
|
||||
.clip(CircleShape)
|
||||
.background(p.accent)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.background(p.brandGradient)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = palette.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) outer.accent else outer.textSecondary,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Подпись секции: заглавные буквы, textMuted, labelSmall. */
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
|
||||
@@ -32,9 +32,19 @@ class SettingsViewModel @Inject constructor(
|
||||
val equalizerPreset: StateFlow<String> = settingsRepository.getEqualizerPreset()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Flat")
|
||||
|
||||
val visualizerStyle: StateFlow<String> = settingsRepository.getVisualizerStyle()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "bars_center")
|
||||
|
||||
val themePalette: StateFlow<String> = settingsRepository.getThemePalette()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "forest")
|
||||
|
||||
val isRecordingEnabled: StateFlow<Boolean> = settingsRepository.isRecordingEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// Предпочитаемый битрейт по умолчанию (0 = авто/станция сама выбирает).
|
||||
val preferredBitrate: StateFlow<Int> = settingsRepository.getPreferredBitrate()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val isLoggedIn: StateFlow<Boolean> = getAuthStateUseCase()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
@@ -57,6 +67,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { settingsRepository.setSleepTimerMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setPreferredBitrate(bitrate: Int) {
|
||||
viewModelScope.launch { settingsRepository.setPreferredBitrate(bitrate) }
|
||||
}
|
||||
|
||||
fun toggleService(serviceId: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = enabledServices.value.toMutableSet()
|
||||
@@ -69,6 +83,14 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { settingsRepository.setEqualizerPreset(preset) }
|
||||
}
|
||||
|
||||
fun setVisualizerStyle(style: String) {
|
||||
viewModelScope.launch { settingsRepository.setVisualizerStyle(style) }
|
||||
}
|
||||
|
||||
fun setThemePalette(id: String) {
|
||||
viewModelScope.launch { settingsRepository.setThemePalette(id) }
|
||||
}
|
||||
|
||||
fun setRecordingEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setRecordingEnabled(enabled) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.radiola.ui.stations
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -9,6 +11,11 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
@@ -34,14 +41,32 @@ fun StationsScreen(
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsState()
|
||||
val nowPlaying by viewModel.nowPlaying.collectAsState()
|
||||
val playingStationId by viewModel.playingStationId.collectAsState()
|
||||
val isPlaying by viewModel.isPlaying.collectAsState()
|
||||
val colors = RadiolaTheme.colors
|
||||
val haptics = LocalHapticFeedback.current
|
||||
// В альбоме шире окно — больше колонок, иначе карточки растягиваются.
|
||||
val gridColumns = if (com.radiola.ui.util.isLandscape()) 4 else 2
|
||||
|
||||
// Полный порядок фильтров: «Все» (null) + жанры. Свайп листает по нему.
|
||||
val orderedTags = remember(tags) { listOf<String?>(null) + tags }
|
||||
fun switchTag(forward: Boolean) {
|
||||
if (orderedTags.size <= 1) return
|
||||
val idx = orderedTags.indexOf(selectedTag).coerceAtLeast(0)
|
||||
val newIdx = idx + if (forward) 1 else -1
|
||||
if (newIdx in orderedTags.indices) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.onTagSelected(orderedTags[newIdx])
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// Двухцветный заголовок экрана
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = colors.textPrimary)) { append("Откройте ") }
|
||||
withStyle(SpanStyle(color = colors.accent)) { append("радио") }
|
||||
withStyle(SpanStyle(color = colors.textPrimary)) { append("Выберите ") }
|
||||
withStyle(SpanStyle(color = colors.accent)) { append("радиостанцию") }
|
||||
},
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 16.dp)
|
||||
@@ -55,18 +80,26 @@ fun StationsScreen(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Жанры — всегда видны
|
||||
if (tags.isNotEmpty()) {
|
||||
FilterChips(
|
||||
tags = tags,
|
||||
selectedTag = selectedTag,
|
||||
onTagSelected = viewModel::onTagSelected
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Область результатов — единственная прокручиваемая зона
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Область результатов — единственная прокручиваемая зона.
|
||||
// Горизонтальный свайп листает фильтры-чипы (вертикаль остаётся у грида).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.pointerInput(orderedTags, selectedTag) {
|
||||
var totalDx = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDx = 0f },
|
||||
onDragEnd = {
|
||||
val threshold = 56.dp.toPx()
|
||||
when {
|
||||
totalDx <= -threshold -> switchTag(forward = true)
|
||||
totalDx >= threshold -> switchTag(forward = false)
|
||||
}
|
||||
}
|
||||
) { _, dragAmount -> totalDx += dragAmount }
|
||||
}
|
||||
) {
|
||||
when {
|
||||
isLoading && stations.isEmpty() -> {
|
||||
CircularProgressIndicator(
|
||||
@@ -105,9 +138,11 @@ fun StationsScreen(
|
||||
}
|
||||
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
columns = GridCells.Fixed(gridColumns),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 4.dp, bottom = 20.dp),
|
||||
// top = высота чипов: грид уходит ПОД них, свечение верхнего ряда
|
||||
// не обрезается и проступает за чипами.
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 54.dp, bottom = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
@@ -117,11 +152,55 @@ fun StationsScreen(
|
||||
isFavorite = favoriteIds.contains(station.id),
|
||||
onClick = { onStationClick(station) },
|
||||
onFavoriteClick = { viewModel.toggleFavorite(station) },
|
||||
nowTrack = nowPlaying[station.id],
|
||||
isCurrent = station.id == playingStationId,
|
||||
isPlaying = isPlaying,
|
||||
modifier = Modifier.animateItemPlacement()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Чипы-фильтры поверх грида. Фон-градиент: вверху непрозрачный
|
||||
// (маскирует прокручиваемые карточки), книзу прозрачный — свечение
|
||||
// верхнего ряда станций проступает ИЗ-ПОД чипов.
|
||||
if (tags.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to colors.bgBase,
|
||||
0.55f to colors.bgBase,
|
||||
1f to Color.Transparent
|
||||
)
|
||||
)
|
||||
.padding(top = 2.dp, bottom = 12.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(44.dp)) {
|
||||
// Чипы во всю ширину, но с отступом слева под кнопку; у левого
|
||||
// края — затухание прозрачности (чипы «уплывают» под кнопку).
|
||||
FilterChips(
|
||||
tags = tags,
|
||||
selectedTag = selectedTag,
|
||||
onTagSelected = viewModel::onTagSelected,
|
||||
contentPadding = PaddingValues(start = 60.dp, end = 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
// Кнопка-категории — поверх чипов, слева.
|
||||
CategoryPicker(
|
||||
title = "Категории",
|
||||
items = tags,
|
||||
selected = selectedTag,
|
||||
onSelect = viewModel::onTagSelected,
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@ package com.radiola.ui.stations
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.radiola.domain.geo.GeoBlock
|
||||
import com.radiola.domain.model.Station
|
||||
import com.radiola.domain.model.Track
|
||||
import com.radiola.domain.repository.FavoritesRepository
|
||||
import com.radiola.domain.repository.NowPlayingRepository
|
||||
import com.radiola.domain.repository.RegionRepository
|
||||
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 com.radiola.service.PlayerController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -21,9 +27,16 @@ class StationsViewModel @Inject constructor(
|
||||
private val playStationUseCase: PlayStationUseCase,
|
||||
private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
|
||||
private val favoritesRepository: FavoritesRepository,
|
||||
private val stationRepository: StationRepository
|
||||
private val stationRepository: StationRepository,
|
||||
private val nowPlayingRepository: NowPlayingRepository,
|
||||
private val regionRepository: RegionRepository,
|
||||
private val playerController: PlayerController
|
||||
) : ViewModel() {
|
||||
|
||||
// Активная (играющая) станция — для подсветки карточки в списке.
|
||||
val playingStationId: StateFlow<Int?> = playerController.currentStationId
|
||||
val isPlaying: StateFlow<Boolean> = playerController.isPlaying
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
@@ -54,20 +67,45 @@ class StationsViewModel @Inject constructor(
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val tags: StateFlow<List<String>> = stationRepository.getTags()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
// Чипы-жанры. Для пользователей из РФ убираем жанры украинских станций
|
||||
// (Radio ROKS, Kiss FM) — их чипы не показываем вовсе.
|
||||
val tags: StateFlow<List<String>> = combine(
|
||||
stationRepository.getTags(),
|
||||
stationRepository.getStations(),
|
||||
regionRepository.countryCode()
|
||||
) { tags, allStations, country ->
|
||||
if (GeoBlock.shouldHideUa(country)) {
|
||||
val uaGenres = allStations.filter { GeoBlock.isUaStation(it) }.map { it.genre }.toSet()
|
||||
tags.filterNot { it in uaGenres }
|
||||
} else {
|
||||
tags
|
||||
}
|
||||
}.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())
|
||||
|
||||
// Текущие треки по id станции (каталожный == station.id) — без коллизий по имени.
|
||||
val nowPlaying: StateFlow<Map<Int, Track>> = nowPlayingRepository.getAllNowPlaying()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
init {
|
||||
// Определяем страну пользователя по IP (для гео-фильтрации станций).
|
||||
viewModelScope.launch { regionRepository.refresh() }
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
refreshStationsUseCase()
|
||||
.onFailure { _error.value = it.localizedMessage ?: "Ошибка загрузки" }
|
||||
_isLoading.value = false
|
||||
}
|
||||
// Периодическое обновление now-playing каждые 20 секунд.
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
nowPlayingRepository.refreshNowPlaying()
|
||||
delay(20_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
|
||||
@@ -23,28 +23,19 @@ import androidx.compose.material3.Text
|
||||
fun brandGradient(): Brush = Brush.linearGradient(listOf(BrandGradientStart, BrandGradientEnd))
|
||||
|
||||
/**
|
||||
* Иконка-марка приложения: градиентный squircle с монограммой «R».
|
||||
* Марка приложения: объёмная 3D-монограмма «R», перекрашенная под текущую тему.
|
||||
*/
|
||||
@Composable
|
||||
fun AppMark(
|
||||
size: Dp = 76.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val radius = (size.value * 0.29f).dp
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(brandGradient()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "R",
|
||||
color = BgBase,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = (size.value * 0.62f).sp
|
||||
)
|
||||
}
|
||||
val palette = RadiolaTheme.palette
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(palette.logoRes),
|
||||
contentDescription = "radiOLA",
|
||||
modifier = modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,16 +46,17 @@ fun RadiolaWordmark(
|
||||
fontSize: Int = 26,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = RadiolaTheme.colors
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "radi",
|
||||
color = TextPrimary,
|
||||
color = colors.textPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = "OLA",
|
||||
color = Accent,
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = fontSize.sp
|
||||
)
|
||||
|
||||
@@ -67,7 +67,10 @@ fun LiveEqualizer(
|
||||
modifier: Modifier = Modifier,
|
||||
barCount: Int = 36,
|
||||
color: Color = Accent,
|
||||
playing: Boolean = true
|
||||
playing: Boolean = true,
|
||||
// Реальный спектр звука (0..1 по полосам). Если задан — рисуем по нему,
|
||||
// иначе — декоративная синус-волна.
|
||||
levels: FloatArray? = null
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "eq")
|
||||
val phase by transition.animateFloat(
|
||||
@@ -79,15 +82,22 @@ fun LiveEqualizer(
|
||||
),
|
||||
label = "eqPhase"
|
||||
)
|
||||
val lv = levels
|
||||
val live = playing && lv != null && lv.isNotEmpty()
|
||||
Canvas(modifier = modifier) {
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = (size.width - gap * (barCount - 1)) / barCount
|
||||
val maxH = size.height
|
||||
for (i in 0 until barCount) {
|
||||
val seed = (i * 0.7f)
|
||||
val wave = if (playing) {
|
||||
0.45f + 0.55f * abs(kotlin.math.sin(phase + seed))
|
||||
} else 0.25f
|
||||
val wave = when {
|
||||
live -> {
|
||||
val idx = (i * lv!!.size / barCount).coerceIn(0, lv.size - 1)
|
||||
0.12f + 0.88f * lv[idx].coerceIn(0f, 1f)
|
||||
}
|
||||
playing -> 0.45f + 0.55f * abs(kotlin.math.sin(phase + seed))
|
||||
else -> 0.25f
|
||||
}
|
||||
val h = maxH * wave
|
||||
val x = i * (barWidth + gap)
|
||||
val y = (maxH - h) / 2f
|
||||
|
||||
133
app/src/main/java/com/radiola/ui/theme/Palettes.kt
Normal file
133
app/src/main/java/com/radiola/ui/theme/Palettes.kt
Normal file
@@ -0,0 +1,133 @@
|
||||
package com.radiola.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.radiola.R
|
||||
|
||||
/**
|
||||
* Цветовые темы приложения. radiOLA всегда тёмная — палитры различаются оттенком
|
||||
* фона, акцентом и брендовым градиентом. Выбор хранится по [id] в настройках
|
||||
* (`SettingsRepository.getThemePalette`), применяется в `RadiolaTheme`.
|
||||
*
|
||||
* Все цвета берутся компонентами через `RadiolaTheme.colors` / `MaterialTheme.colorScheme`,
|
||||
* поэтому смена палитры перекрашивает приложение мгновенно.
|
||||
*/
|
||||
enum class ThemePalette(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val colors: RadiolaColors,
|
||||
) {
|
||||
// Фирменная зелёная (по умолчанию) — берёт значения из Color.kt.
|
||||
FOREST(
|
||||
"forest", "Лес",
|
||||
RadiolaColors(
|
||||
bgBase = BgBase, surface = BgSurface, surface2 = BgSurface2, elevated = BgElevated,
|
||||
accent = Accent, accentDim = AccentDim,
|
||||
textPrimary = TextPrimary, textSecondary = TextSecondary, textMuted = TextMuted,
|
||||
border = BorderColor, live = LiveRed,
|
||||
gradientStart = BrandGradientStart, gradientEnd = BrandGradientEnd,
|
||||
),
|
||||
),
|
||||
|
||||
// Глубокий сине-бирюзовый, акцент циан.
|
||||
OCEAN(
|
||||
"ocean", "Океан",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF0A0F1A), surface = Color(0xFF121A2A), surface2 = Color(0xFF1A2438), elevated = Color(0xFF222F47),
|
||||
accent = Color(0xFF4FD6E0), accentDim = Color(0xFF2E8E9A),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFF8F9DB3), textMuted = Color(0xFF5C6A82),
|
||||
border = Color(0xFF26324A), live = Color(0xFFFF5C7A),
|
||||
gradientStart = Color(0xFF5BE1F2), gradientEnd = Color(0xFF3A7BD5),
|
||||
),
|
||||
),
|
||||
|
||||
// Тёплый тёмный, коралл/оранжевый.
|
||||
SUNSET(
|
||||
"sunset", "Закат",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF1A0F0C), surface = Color(0xFF261712), surface2 = Color(0xFF33201A), elevated = Color(0xFF422A22),
|
||||
accent = Color(0xFFFF8A5B), accentDim = Color(0xFFC55E3A),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB39A8F), textMuted = Color(0xFF82675C),
|
||||
border = Color(0xFF4A322A), live = Color(0xFFFF4D6D),
|
||||
gradientStart = Color(0xFFFFB36B), gradientEnd = Color(0xFFFF6B5B),
|
||||
),
|
||||
),
|
||||
|
||||
// Тёмно-фиолетовый, акцент сирень.
|
||||
AMETHYST(
|
||||
"amethyst", "Аметист",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF120E1A), surface = Color(0xFF1C1528), surface2 = Color(0xFF271D38), elevated = Color(0xFF332747),
|
||||
accent = Color(0xFFB388FF), accentDim = Color(0xFF7E5BC5),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFA095B3), textMuted = Color(0xFF6E5C82),
|
||||
border = Color(0xFF372A4A), live = Color(0xFFFF5C9D),
|
||||
gradientStart = Color(0xFFC9A6FF), gradientEnd = Color(0xFF8B5BD5),
|
||||
),
|
||||
),
|
||||
|
||||
// Почти чёрный, неоновый розово-малиновый (киберпанк).
|
||||
NEON(
|
||||
"neon", "Неон",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF0D0A12), surface = Color(0xFF16111E), surface2 = Color(0xFF1F1729), elevated = Color(0xFF2A1F38),
|
||||
accent = Color(0xFFFF4D9D), accentDim = Color(0xFFC53A75),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB095A6), textMuted = Color(0xFF7C5C6E),
|
||||
border = Color(0xFF3A2A33), live = Color(0xFFFF3D5C),
|
||||
gradientStart = Color(0xFFFF5BD0), gradientEnd = Color(0xFFFF4D7A),
|
||||
),
|
||||
),
|
||||
|
||||
// Тёплый уголь, золотой акцент.
|
||||
AMBER(
|
||||
"amber", "Янтарь",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF14110A), surface = Color(0xFF201A10), surface2 = Color(0xFF2C2418), elevated = Color(0xFF392F20),
|
||||
accent = Color(0xFFFFC247), accentDim = Color(0xFFC5902E),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB3A88F), textMuted = Color(0xFF82765C),
|
||||
border = Color(0xFF4A3F2A), live = Color(0xFFFF5C52),
|
||||
gradientStart = Color(0xFFFFD66B), gradientEnd = Color(0xFFFF9F45),
|
||||
),
|
||||
),
|
||||
|
||||
// Холодный графит, ледяной голубой.
|
||||
ICE(
|
||||
"ice", "Лёд",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF0C1014), surface = Color(0xFF161C22), surface2 = Color(0xFF202830), elevated = Color(0xFF2C3640),
|
||||
accent = Color(0xFF7FB3FF), accentDim = Color(0xFF4F7CC5),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFF95A3B3), textMuted = Color(0xFF5C6A7C),
|
||||
border = Color(0xFF2A3540), live = Color(0xFFFF6B6B),
|
||||
gradientStart = Color(0xFFA6D0FF), gradientEnd = Color(0xFF6B9FFF),
|
||||
),
|
||||
),
|
||||
|
||||
// Тёмная мальва, розовый акцент.
|
||||
ROSE(
|
||||
"rose", "Роза",
|
||||
RadiolaColors(
|
||||
bgBase = Color(0xFF160E12), surface = Color(0xFF22151B), surface2 = Color(0xFF2E1E26), elevated = Color(0xFF3C2A33),
|
||||
accent = Color(0xFFFF7EA8), accentDim = Color(0xFFC5547A),
|
||||
textPrimary = Color(0xFFFFFFFF), textSecondary = Color(0xFFB395A0), textMuted = Color(0xFF825C6A),
|
||||
border = Color(0xFF4A2A38), live = Color(0xFFFF4D6D),
|
||||
gradientStart = Color(0xFFFFA6C2), gradientEnd = Color(0xFFFF6B9F),
|
||||
),
|
||||
),
|
||||
;
|
||||
|
||||
/** Перекрашенный под эту тему 3D-логотип (drawable). */
|
||||
val logoRes: Int
|
||||
get() = when (this) {
|
||||
FOREST -> R.drawable.logo_forest
|
||||
OCEAN -> R.drawable.logo_ocean
|
||||
SUNSET -> R.drawable.logo_sunset
|
||||
AMETHYST -> R.drawable.logo_amethyst
|
||||
NEON -> R.drawable.logo_neon
|
||||
AMBER -> R.drawable.logo_amber
|
||||
ICE -> R.drawable.logo_ice
|
||||
ROSE -> R.drawable.logo_rose
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Палитра по сохранённому id (фолбэк — «Лес»). */
|
||||
fun fromId(id: String?): ThemePalette = entries.firstOrNull { it.id == id } ?: FOREST
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user