From 69682268f324864a48e6c1165dd9b56da0650929 Mon Sep 17 00:00:00 2001 From: nk Date: Sun, 7 Jun 2026 18:38:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(app):=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=A0=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D1=80=D0=B5=D0=BA=C2=BB=20(Shazam)=20+=20?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - кнопка распознавания в плеере: видна только на музыкальных станциях без метаданных эфира (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 --- .../com/radiola/data/local/AppDatabase.kt | 25 +++++++- .../data/local/dao/RecognizedTrackDao.kt | 20 +++++++ .../local/entity/RecognizedTrackEntity.kt | 14 +++++ .../com/radiola/data/remote/RadiolaApi.kt | 5 ++ .../data/remote/dto/RecognizeResponseDto.kt | 12 ++++ .../RecognizedTrackRepositoryImpl.kt | 34 +++++++++++ .../data/repository/ShazamRepositoryImpl.kt | 40 +++++++++++++ app/src/main/java/com/radiola/di/AppModule.kt | 15 ++++- .../com/radiola/domain/model/MusicGenres.kt | 18 ++++++ .../repository/RecognizedTrackRepository.kt | 9 +++ .../domain/repository/ShazamRepository.kt | 13 +++++ .../com/radiola/ui/history/HistoryScreen.kt | 57 ++++++++++++++++--- .../radiola/ui/history/HistoryViewModel.kt | 33 ++++++----- .../radiola/ui/player/PlayerBottomSheet.kt | 57 +++++++++++++++++++ .../com/radiola/ui/player/PlayerViewModel.kt | 45 +++++++++++++++ backend | 2 +- 16 files changed, 371 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/radiola/data/local/dao/RecognizedTrackDao.kt create mode 100644 app/src/main/java/com/radiola/data/local/entity/RecognizedTrackEntity.kt create mode 100644 app/src/main/java/com/radiola/data/remote/dto/RecognizeResponseDto.kt create mode 100644 app/src/main/java/com/radiola/data/repository/RecognizedTrackRepositoryImpl.kt create mode 100644 app/src/main/java/com/radiola/data/repository/ShazamRepositoryImpl.kt create mode 100644 app/src/main/java/com/radiola/domain/model/MusicGenres.kt create mode 100644 app/src/main/java/com/radiola/domain/repository/RecognizedTrackRepository.kt create mode 100644 app/src/main/java/com/radiola/domain/repository/ShazamRepository.kt diff --git a/app/src/main/java/com/radiola/data/local/AppDatabase.kt b/app/src/main/java/com/radiola/data/local/AppDatabase.kt index 5d1ce93..98fcecd 100644 --- a/app/src/main/java/com/radiola/data/local/AppDatabase.kt +++ b/app/src/main/java/com/radiola/data/local/AppDatabase.kt @@ -5,11 +5,13 @@ 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 @@ -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( - entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class], - version = 7 + entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class, RecognizedTrackEntity::class], + version = 8 ) abstract class AppDatabase : RoomDatabase() { abstract fun stationDao(): StationDao @@ -88,4 +108,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun tagDao(): TagDao abstract fun recordingDao(): RecordingDao abstract fun alarmDao(): AlarmDao + abstract fun recognizedTrackDao(): RecognizedTrackDao } diff --git a/app/src/main/java/com/radiola/data/local/dao/RecognizedTrackDao.kt b/app/src/main/java/com/radiola/data/local/dao/RecognizedTrackDao.kt new file mode 100644 index 0000000..e738bbe --- /dev/null +++ b/app/src/main/java/com/radiola/data/local/dao/RecognizedTrackDao.kt @@ -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> + + @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() +} diff --git a/app/src/main/java/com/radiola/data/local/entity/RecognizedTrackEntity.kt b/app/src/main/java/com/radiola/data/local/entity/RecognizedTrackEntity.kt new file mode 100644 index 0000000..26dfe62 --- /dev/null +++ b/app/src/main/java/com/radiola/data/local/entity/RecognizedTrackEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt index 1a3fa9c..e6c8227 100644 --- a/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt +++ b/app/src/main/java/com/radiola/data/remote/RadiolaApi.kt @@ -8,6 +8,7 @@ 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 @@ -30,6 +31,10 @@ interface RadiolaApi { @GET("now-playing") suspend fun getNowPlaying(): List + // Распознавание играющего трека через 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 diff --git a/app/src/main/java/com/radiola/data/remote/dto/RecognizeResponseDto.kt b/app/src/main/java/com/radiola/data/remote/dto/RecognizeResponseDto.kt new file mode 100644 index 0000000..5f56fcb --- /dev/null +++ b/app/src/main/java/com/radiola/data/remote/dto/RecognizeResponseDto.kt @@ -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 +) diff --git a/app/src/main/java/com/radiola/data/repository/RecognizedTrackRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/RecognizedTrackRepositoryImpl.kt new file mode 100644 index 0000000..fd90bc3 --- /dev/null +++ b/app/src/main/java/com/radiola/data/repository/RecognizedTrackRepositoryImpl.kt @@ -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> = + 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 + ) +} diff --git a/app/src/main/java/com/radiola/data/repository/ShazamRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/ShazamRepositoryImpl.kt new file mode 100644 index 0000000..bf10a78 --- /dev/null +++ b/app/src/main/java/com/radiola/data/repository/ShazamRepositoryImpl.kt @@ -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("Нет связи с сервером") + } + } +} diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index bf1b4b6..ee4d406 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -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_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 @@ -26,8 +27,12 @@ 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 @@ -162,7 +167,7 @@ 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, 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() @Provides @@ -223,4 +228,12 @@ object AppModule { @Provides @Singleton fun provideLyricsRepository(impl: LyricsRepositoryImpl): LyricsRepository = impl + + @Provides + @Singleton + fun provideRecognizedTrackRepository(impl: RecognizedTrackRepositoryImpl): RecognizedTrackRepository = impl + + @Provides + @Singleton + fun provideShazamRepository(impl: ShazamRepositoryImpl): ShazamRepository = impl } diff --git a/app/src/main/java/com/radiola/domain/model/MusicGenres.kt b/app/src/main/java/com/radiola/domain/model/MusicGenres.kt new file mode 100644 index 0000000..d6d7d2f --- /dev/null +++ b/app/src/main/java/com/radiola/domain/model/MusicGenres.kt @@ -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 + } +} diff --git a/app/src/main/java/com/radiola/domain/repository/RecognizedTrackRepository.kt b/app/src/main/java/com/radiola/domain/repository/RecognizedTrackRepository.kt new file mode 100644 index 0000000..6bd1aa9 --- /dev/null +++ b/app/src/main/java/com/radiola/domain/repository/RecognizedTrackRepository.kt @@ -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> + suspend fun addTrack(track: Track) +} diff --git a/app/src/main/java/com/radiola/domain/repository/ShazamRepository.kt b/app/src/main/java/com/radiola/domain/repository/ShazamRepository.kt new file mode 100644 index 0000000..edefed0 --- /dev/null +++ b/app/src/main/java/com/radiola/domain/repository/ShazamRepository.kt @@ -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 +} diff --git a/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt b/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt index bb89fae..ffef9b4 100644 --- a/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/radiola/ui/history/HistoryScreen.kt @@ -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(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 } diff --git a/app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt b/app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt index 141b625..21507a4 100644 --- a/app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/radiola/ui/history/HistoryViewModel.kt @@ -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 = _searchQuery.asStateFlow() - val history: StateFlow> = 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>): StateFlow> = + 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> = filtered(trackHistoryRepository.getHistory()) + + // Распознанные через Shazam + val recognized: StateFlow> = filtered(recognizedTrackRepository.getHistory()) fun onSearchQueryChange(query: String) { _searchQuery.value = query } - - fun removeTrack(track: Track) { - viewModelScope.launch { - trackHistoryRepository.removeTrack(track) - } - } } diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 1ea3f5c..30ff0fb 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -79,8 +79,15 @@ 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 + + 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) } @@ -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 = { // Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost — // чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf. @@ -408,6 +463,7 @@ fun PlayerBottomSheet( horizontalAlignment = Alignment.CenterHorizontally ) { trackInfoSection() + recognizeSection() Spacer(Modifier.height(16.dp)) visualizerSection() Spacer(Modifier.height(16.dp)) @@ -441,6 +497,7 @@ fun PlayerBottomSheet( coverSection(190.dp) Spacer(Modifier.height(14.dp)) trackInfoSection() + recognizeSection() Spacer(Modifier.height(20.dp)) visualizerSection() Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index f70fb22..716dad4 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -13,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 @@ -34,6 +37,8 @@ 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, @@ -54,6 +59,17 @@ class PlayerViewModel @Inject constructor( private val _currentTrack = MutableStateFlow(null) val currentTrack: StateFlow = _currentTrack.asStateFlow() + // Распознавание трека (Shazam) — индикатор и одноразовые сообщения для UI. + private val _recognizing = MutableStateFlow(false) + val recognizing: StateFlow = _recognizing.asStateFlow() + + private val _recognizeEvent = MutableSharedFlow(extraBufferCapacity = 1) + val recognizeEvent: SharedFlow = _recognizeEvent.asSharedFlow() + + // Ключ трека, добавленного через распознавание — его НЕ дублируем в историю + // «эфирных» треков (он идёт в отдельную историю распознанных). + private var recognizedKey: String? = null + private val _enabledServices = MutableStateFlow>(emptyList()) val enabledServices: StateFlow> = _enabledServices.asStateFlow() @@ -103,6 +119,8 @@ class PlayerViewModel @Inject constructor( .filterNotNull() .distinctUntilChanged() .collect { track -> + // Распознанный трек уже в истории распознанных — не дублируем в эфирную. + if (trackKey(track) == recognizedKey) return@collect trackHistoryRepository.addTrack(track) } } @@ -114,6 +132,7 @@ class PlayerViewModel @Inject constructor( recordingPlaybackController.stop() _currentStation.value = station _currentTrack.value = null + recognizedKey = null _playlist.value = playlist ?: _stations.value // Выбираем стартовое качество: предпочтение пользователя → совпадение с // потоком по умолчанию → высшее. Если вариантов нет — играем как есть. @@ -264,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) diff --git a/backend b/backend index d082a1c..1616c23 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit d082a1ce0776f6c75c5308762c3ba426e2dc1d52 +Subproject commit 1616c231b72984385f2e751fb8d4482ca5bff790