feat(app): кнопка «Распознать трек» (Shazam) + история распознанных

- кнопка распознавания в плеере: видна только на музыкальных станциях без
  метаданных эфира (track == null), показывает спиннер и результат через Toast
- распознанный трек отображается в плеере и пишется в ОТДЕЛЬНУЮ историю
  распознанных (не дублируется в историю эфирных треков — гейт по ключу)
- экран Истории: переключатель «Треки эфира | Распознанные», два списка
- Room: таблица recognized_track (миграция 7→8), DAO/репозиторий
- ShazamRepository → POST /shazam/recognize/{stationId}, маппинг 503/400 в текст
- MusicGenres.isMusicStation — клиентский гейт (синхронизирован с бэкендом)
- bump backend submodule (модуль shazam)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-07 18:38:17 +03:00
parent 251809df33
commit 69682268f3
16 changed files with 371 additions and 28 deletions

View File

@@ -5,11 +5,13 @@ import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.radiola.data.local.dao.AlarmDao 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.RecordingDao
import com.radiola.data.local.dao.StationDao import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao import com.radiola.data.local.dao.TrackHistoryDao
import com.radiola.data.local.entity.AlarmEntity 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.RecordingEntity
import com.radiola.data.local.entity.StationEntity import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity import com.radiola.data.local.entity.TagEntity
@@ -78,9 +80,27 @@ val MIGRATION_6_7 = object : Migration(6, 7) {
} }
} }
// Добавляем таблицу истории распознанных треков (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( @Database(
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class], entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class, RecognizedTrackEntity::class],
version = 7 version = 8
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao abstract fun stationDao(): StationDao
@@ -88,4 +108,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun tagDao(): TagDao abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao abstract fun recordingDao(): RecordingDao
abstract fun alarmDao(): AlarmDao abstract fun alarmDao(): AlarmDao
abstract fun recognizedTrackDao(): RecognizedTrackDao
} }

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import com.radiola.data.remote.dto.GenresResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto 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.TrackStatsDto
import com.radiola.data.remote.dto.UserSettingsDto import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -30,6 +31,10 @@ interface RadiolaApi {
@GET("now-playing") @GET("now-playing")
suspend fun getNowPlaying(): List<BackendNowPlayingDto> suspend fun getNowPlaying(): List<BackendNowPlayingDto>
// Распознавание играющего трека через Shazam (бэкенд сам тянет аудио из потока).
@POST("shazam/recognize/{stationId}")
suspend fun recognizeTrack(@Path("stationId") stationId: Int): RecognizeResponseDto
// Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager). // Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager).
@POST("covers/submit") @POST("covers/submit")
suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
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 -> "На этой станции нет музыки"
else -> "Не удалось распознать трек"
}
RecognizeResult.Error(msg)
} catch (e: Exception) {
RecognizeResult.Error("Нет связи с сервером")
}
}
}

View File

@@ -10,6 +10,7 @@ import com.radiola.data.local.MIGRATION_3_4
import com.radiola.data.local.MIGRATION_4_5 import com.radiola.data.local.MIGRATION_4_5
import com.radiola.data.local.MIGRATION_5_6 import com.radiola.data.local.MIGRATION_5_6
import com.radiola.data.local.MIGRATION_6_7 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.local.dao.AlarmDao
import com.radiola.data.remote.AuthInterceptor import com.radiola.data.remote.AuthInterceptor
import com.radiola.data.remote.LrcLibApi import com.radiola.data.remote.LrcLibApi
@@ -26,8 +27,12 @@ import com.radiola.data.repository.RegionRepositoryImpl
import com.radiola.data.repository.SettingsRepositoryImpl import com.radiola.data.repository.SettingsRepositoryImpl
import com.radiola.data.repository.StationRepositoryImpl import com.radiola.data.repository.StationRepositoryImpl
import com.radiola.data.repository.SyncRepositoryImpl 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.data.repository.TrackHistoryRepositoryImpl
import com.radiola.domain.repository.AuthRepository 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.ChartsRepository
import com.radiola.domain.repository.FavoritesRepository import com.radiola.domain.repository.FavoritesRepository
import com.radiola.domain.repository.LyricsRepository import com.radiola.domain.repository.LyricsRepository
@@ -162,7 +167,7 @@ object AppModule {
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase = fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db") Room.databaseBuilder(context, AppDatabase::class.java, "radiola.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
.build() .build()
@Provides @Provides
@@ -223,4 +228,12 @@ object AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl
@Provides
@Singleton
fun provideRecognizedTrackRepository(impl: RecognizedTrackRepositoryImpl): RecognizedTrackRepository = impl
@Provides
@Singleton
fun provideShazamRepository(impl: ShazamRepositoryImpl): ShazamRepository = impl
} }

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

View File

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

View File

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

View File

@@ -5,19 +5,26 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.ExperimentalFoundationApi 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.composables.icons.lucide.History import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.ui.components.DeeplinkBottomSheet import com.radiola.ui.components.DeeplinkBottomSheet
import com.radiola.ui.components.EmptyState import com.radiola.ui.components.EmptyState
@@ -32,10 +39,14 @@ fun HistoryScreen(
viewModel: HistoryViewModel = hiltViewModel() viewModel: HistoryViewModel = hiltViewModel()
) { ) {
val history by viewModel.history.collectAsState() val history by viewModel.history.collectAsState()
val recognized by viewModel.recognized.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
var selectedTrack by remember { mutableStateOf<Track?>(null) } var selectedTrack by remember { mutableStateOf<Track?>(null) }
var tab by remember { mutableStateOf(0) }
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val items = if (tab == 0) history else recognized
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -51,6 +62,30 @@ fun HistoryScreen(
modifier = Modifier.padding(top = 20.dp, bottom = 16.dp) 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( SearchBar(
query = searchQuery, query = searchQuery,
onQueryChange = viewModel::onSearchQueryChange, onQueryChange = viewModel::onSearchQueryChange,
@@ -59,7 +94,7 @@ fun HistoryScreen(
) )
Crossfade( Crossfade(
targetState = history.isEmpty(), targetState = items.isEmpty(),
label = "historyState" label = "historyState"
) { isEmpty -> ) { isEmpty ->
if (isEmpty) { if (isEmpty) {
@@ -67,18 +102,26 @@ fun HistoryScreen(
visible = true, visible = true,
enter = fadeIn() + slideInVertically() enter = fadeIn() + slideInVertically()
) { ) {
EmptyState( if (tab == 1) {
message = "История пуста", EmptyState(
icon = Lucide.History, message = "Пока ничего не распознано",
modifier = Modifier.fillMaxSize() icon = Lucide.Mic,
) modifier = Modifier.fillMaxSize()
)
} else {
EmptyState(
message = "История пуста",
icon = Lucide.History,
modifier = Modifier.fillMaxSize()
)
}
} }
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp) contentPadding = PaddingValues(bottom = 16.dp)
) { ) {
items(history) { track -> items(items) { track ->
TrackListItem( TrackListItem(
track = track, track = track,
onClick = { selectedTrack = track } onClick = { selectedTrack = track }

View File

@@ -3,37 +3,36 @@ package com.radiola.ui.history
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.radiola.domain.model.Track import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizedTrackRepository
import com.radiola.domain.repository.TrackHistoryRepository import com.radiola.domain.repository.TrackHistoryRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HistoryViewModel @Inject constructor( class HistoryViewModel @Inject constructor(
private val trackHistoryRepository: TrackHistoryRepository private val trackHistoryRepository: TrackHistoryRepository,
private val recognizedTrackRepository: RecognizedTrackRepository
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val history: StateFlow<List<Track>> = combine( private fun filtered(source: Flow<List<Track>>): StateFlow<List<Track>> =
trackHistoryRepository.getHistory(), combine(source, _searchQuery) { tracks, query ->
_searchQuery if (query.isBlank()) tracks else tracks.filter {
) { tracks, query -> it.artist.contains(query, ignoreCase = true) ||
if (query.isBlank()) tracks else tracks.filter { it.song.contains(query, ignoreCase = true)
it.artist.contains(query, ignoreCase = true) || }
it.song.contains(query, ignoreCase = true) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
}.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) { fun onSearchQueryChange(query: String) {
_searchQuery.value = query _searchQuery.value = query
} }
fun removeTrack(track: Track) {
viewModelScope.launch {
trackHistoryRepository.removeTrack(track)
}
}
} }

View File

@@ -79,8 +79,15 @@ fun PlayerBottomSheet(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val enabledServices by viewModel.enabledServices.collectAsState() val enabledServices by viewModel.enabledServices.collectAsState()
val recognizing by viewModel.recognizing.collectAsState()
val colors = RadiolaTheme.colors val colors = RadiolaTheme.colors
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
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 showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) } var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) } var showSleep by remember { mutableStateOf(false) }
@@ -186,6 +193,54 @@ fun PlayerBottomSheet(
} }
} }
// Кнопка распознавания (Shazam) — только для музыкальных станций без метаданных эфира.
val recognizeSection: @Composable () -> Unit = {
val show = station != null &&
track == null &&
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 = { val visualizerSection: @Composable () -> Unit = {
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost — // Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf. // чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
@@ -408,6 +463,7 @@ fun PlayerBottomSheet(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
trackInfoSection() trackInfoSection()
recognizeSection()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
visualizerSection() visualizerSection()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -441,6 +497,7 @@ fun PlayerBottomSheet(
coverSection(190.dp) coverSection(190.dp)
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
trackInfoSection() trackInfoSection()
recognizeSection()
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
visualizerSection() visualizerSection()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@@ -13,6 +13,9 @@ import com.radiola.domain.repository.RecordingRepository
import com.radiola.domain.usecase.GetNowPlayingUseCase import com.radiola.domain.usecase.GetNowPlayingUseCase
import com.radiola.domain.usecase.GetStationsUseCase import com.radiola.domain.usecase.GetStationsUseCase
import com.radiola.domain.usecase.SearchTrackInServiceUseCase 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.repository.TrackHistoryRepository
import com.radiola.domain.usecase.ToggleFavoriteUseCase import com.radiola.domain.usecase.ToggleFavoriteUseCase
import com.radiola.domain.usecase.auth.PushHistoryUseCase import com.radiola.domain.usecase.auth.PushHistoryUseCase
@@ -34,6 +37,8 @@ class PlayerViewModel @Inject constructor(
private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase, private val searchTrackInServiceUseCase: SearchTrackInServiceUseCase,
private val toggleFavoriteUseCase: ToggleFavoriteUseCase, private val toggleFavoriteUseCase: ToggleFavoriteUseCase,
private val trackHistoryRepository: TrackHistoryRepository, private val trackHistoryRepository: TrackHistoryRepository,
private val recognizedTrackRepository: RecognizedTrackRepository,
private val shazamRepository: ShazamRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val recordingRepository: RecordingRepository, private val recordingRepository: RecordingRepository,
private val pushHistoryUseCase: PushHistoryUseCase, private val pushHistoryUseCase: PushHistoryUseCase,
@@ -54,6 +59,17 @@ class PlayerViewModel @Inject constructor(
private val _currentTrack = MutableStateFlow<Track?>(null) private val _currentTrack = MutableStateFlow<Track?>(null)
val currentTrack: StateFlow<Track?> = _currentTrack.asStateFlow() 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()) private val _enabledServices = MutableStateFlow<List<DeeplinkService>>(emptyList())
val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow() val enabledServices: StateFlow<List<DeeplinkService>> = _enabledServices.asStateFlow()
@@ -103,6 +119,8 @@ class PlayerViewModel @Inject constructor(
.filterNotNull() .filterNotNull()
.distinctUntilChanged() .distinctUntilChanged()
.collect { track -> .collect { track ->
// Распознанный трек уже в истории распознанных — не дублируем в эфирную.
if (trackKey(track) == recognizedKey) return@collect
trackHistoryRepository.addTrack(track) trackHistoryRepository.addTrack(track)
} }
} }
@@ -114,6 +132,7 @@ class PlayerViewModel @Inject constructor(
recordingPlaybackController.stop() recordingPlaybackController.stop()
_currentStation.value = station _currentStation.value = station
_currentTrack.value = null _currentTrack.value = null
recognizedKey = null
_playlist.value = playlist ?: _stations.value _playlist.value = playlist ?: _stations.value
// Выбираем стартовое качество: предпочтение пользователя → совпадение с // Выбираем стартовое качество: предпочтение пользователя → совпадение с
// потоком по умолчанию → высшее. Если вариантов нет — играем как есть. // потоком по умолчанию → высшее. Если вариантов нет — играем как есть.
@@ -264,6 +283,32 @@ class PlayerViewModel @Inject constructor(
return searchTrackInServiceUseCase(track, service) 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) { fun toggleFavorite(station: Station) {
viewModelScope.launch { viewModelScope.launch {
toggleFavoriteUseCase(station) toggleFavoriteUseCase(station)

Submodule backend updated: d082a1ce07...1616c231b7