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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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("Нет связи с сервером")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
2
backend
2
backend
Submodule backend updated: d082a1ce07...1616c231b7
Reference in New Issue
Block a user