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 880592c..96750b8 100644 --- a/app/src/main/java/com/radiola/data/local/AppDatabase.kt +++ b/app/src/main/java/com/radiola/data/local/AppDatabase.kt @@ -50,9 +50,15 @@ val MIGRATION_4_5 = object : Migration(4, 5) { } } +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE recordings ADD COLUMN markers TEXT NOT NULL DEFAULT ''") + } +} + @Database( entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class], - version = 5 + version = 6 ) abstract class AppDatabase : RoomDatabase() { abstract fun stationDao(): StationDao diff --git a/app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt b/app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt index 7451e1c..3b67720 100644 --- a/app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt +++ b/app/src/main/java/com/radiola/data/local/dao/RecordingDao.kt @@ -21,6 +21,9 @@ interface RecordingDao { @Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id") suspend fun updateEndTime(id: Long, endTime: Long, duration: Long) + @Query("UPDATE recordings SET markers = :markers WHERE id = :id") + suspend fun updateMarkers(id: Long, markers: String) + @Query("SELECT * FROM recordings WHERE id = :id") suspend fun getById(id: Long): RecordingEntity? } diff --git a/app/src/main/java/com/radiola/data/local/entity/RecordingEntity.kt b/app/src/main/java/com/radiola/data/local/entity/RecordingEntity.kt index 6655428..b5e2a1d 100644 --- a/app/src/main/java/com/radiola/data/local/entity/RecordingEntity.kt +++ b/app/src/main/java/com/radiola/data/local/entity/RecordingEntity.kt @@ -12,5 +12,7 @@ data class RecordingEntity( val startTime: Long, val endTime: Long?, val trackName: String?, - val duration: Long? + val duration: Long?, + // Тайм-коды треков: строки "offsetMs\tartist\tsong", разделённые \n. + val markers: String = "" ) diff --git a/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt b/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt index 924cfe9..cb7114f 100644 --- a/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt +++ b/app/src/main/java/com/radiola/data/repository/RecordingRepositoryImpl.kt @@ -10,6 +10,8 @@ import com.radiola.data.local.entity.RecordingEntity import com.radiola.domain.model.Recording import com.radiola.domain.model.Station import com.radiola.domain.model.Track +import com.radiola.domain.model.TrackMarker +import com.radiola.domain.repository.NowPlayingRepository import com.radiola.domain.repository.RecordingRepository import com.radiola.service.RecordingService import dagger.hilt.android.qualifiers.ApplicationContext @@ -17,6 +19,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map import okhttp3.OkHttpClient import okhttp3.Request @@ -28,6 +31,7 @@ import javax.inject.Inject class RecordingRepositoryImpl @Inject constructor( private val db: AppDatabase, private val okHttpClient: OkHttpClient, + private val nowPlayingRepository: NowPlayingRepository, @ApplicationContext private val context: Context ) : RecordingRepository { @@ -35,6 +39,7 @@ class RecordingRepositoryImpl @Inject constructor( override val isRecording: StateFlow = _isRecording.asStateFlow() private var recordingJob: Job? = null + private var markerJob: Job? = null private var currentCall: okhttp3.Call? = null private var currentRecordingId: Long? = null @@ -74,6 +79,36 @@ class RecordingRepositoryImpl @Inject constructor( db.recordingDao().insert(entity) _isRecording.value = true + // Захват тайм-кодов треков: первый трек на 0, далее по смене now-playing. + val markers = mutableListOf() + track?.let { + if (it.artist.isNotBlank() || it.song.isNotBlank()) { + markers.add(TrackMarker(0L, it.artist, it.song)) + } + } + markerJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + if (markers.isNotEmpty()) { + try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {} + } + // Свой поллинг now-playing — чтобы метки писались независимо от экрана радио + launch { + while (isActive) { + try { nowPlayingRepository.refreshNowPlaying() } catch (_: Exception) {} + delay(15_000) + } + } + nowPlayingRepository.getNowPlaying(station.id) + .distinctUntilChangedBy { "${it?.artist}|${it?.song}" } + .collect { t -> + if (t == null || (t.artist.isBlank() && t.song.isBlank())) return@collect + val last = markers.lastOrNull() + if (last != null && last.artist == t.artist && last.song == t.song) return@collect + val offset = (System.currentTimeMillis() - id).coerceAtLeast(0L) + markers.add(TrackMarker(offset, t.artist, t.song)) + try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {} + } + } + // Start foreground service to keep process alive during recording val serviceIntent = Intent(context, RecordingService::class.java).apply { putExtra(RecordingService.EXTRA_STATION_NAME, station.name) @@ -125,6 +160,8 @@ class RecordingRepositoryImpl @Inject constructor( currentCall = null recordingJob?.cancelAndJoin() recordingJob = null + markerJob?.cancel() + markerJob = null _isRecording.value = false // Stop foreground service @@ -158,6 +195,22 @@ class RecordingRepositoryImpl @Inject constructor( startTime = startTime, endTime = endTime, trackName = trackName, - duration = duration + duration = duration, + markers = decodeMarkers(markers) ) + + // Метки кодируем строкой "offsetMs\tartist\tsong" по строкам \n + // (названия треков не содержат \t/\n). + private fun encodeMarkers(list: List): String = + list.joinToString("\n") { "${it.offsetMs}\t${it.artist}\t${it.song}" } + + private fun decodeMarkers(raw: String): List { + if (raw.isBlank()) return emptyList() + return raw.split("\n").mapNotNull { line -> + val p = line.split("\t") + if (p.size != 3) return@mapNotNull null + val off = p[0].toLongOrNull() ?: return@mapNotNull null + TrackMarker(off, p[1], p[2]) + } + } } diff --git a/app/src/main/java/com/radiola/di/AppModule.kt b/app/src/main/java/com/radiola/di/AppModule.kt index 33ef591..0f02f84 100644 --- a/app/src/main/java/com/radiola/di/AppModule.kt +++ b/app/src/main/java/com/radiola/di/AppModule.kt @@ -8,6 +8,7 @@ import com.radiola.data.local.MIGRATION_1_2 import com.radiola.data.local.MIGRATION_2_3 import com.radiola.data.local.MIGRATION_3_4 import com.radiola.data.local.MIGRATION_4_5 +import com.radiola.data.local.MIGRATION_5_6 import com.radiola.data.remote.AuthInterceptor import com.radiola.data.remote.LrcLibApi import com.radiola.data.remote.LoveApi @@ -137,7 +138,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) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .build() @Provides diff --git a/app/src/main/java/com/radiola/domain/model/Recording.kt b/app/src/main/java/com/radiola/domain/model/Recording.kt index be5f946..647c9e1 100644 --- a/app/src/main/java/com/radiola/domain/model/Recording.kt +++ b/app/src/main/java/com/radiola/domain/model/Recording.kt @@ -8,5 +8,17 @@ data class Recording( val startTime: Long, val endTime: Long?, val trackName: String?, - val duration: Long? + val duration: Long?, + // Тайм-коды треков, звучавших во время записи (для навигации при прослушивании). + val markers: List = emptyList() ) + +/** Отметка трека в записи: смещение от начала записи + что играло. */ +data class TrackMarker( + val offsetMs: Long, + val artist: String, + val song: String +) { + val title: String + get() = listOf(artist, song).filter { it.isNotBlank() }.joinToString(" — ") +} diff --git a/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt index b9631d9..176f597 100644 --- a/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt +++ b/app/src/main/java/com/radiola/service/RecordingPlaybackController.kt @@ -5,6 +5,8 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.extractor.DefaultExtractorsFactory import com.radiola.domain.model.Recording import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -41,7 +43,16 @@ class RecordingPlaybackController @Inject constructor( private val _durationMs = MutableStateFlow(0L) val durationMs: StateFlow = _durationMs - private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context).build().apply { + // Записи эфира — сырые ADTS-AAC/MP3 без контейнера и индексов перемотки. + // Включаем CBR-seeking, иначе ExoPlayer считает поток неперематываемым + // (seekTo не работал, запись всегда стартовала с начала). + private val extractorsFactory = DefaultExtractorsFactory() + .setConstantBitrateSeekingEnabled(true) + .setConstantBitrateSeekingAlwaysEnabled(true) + + private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory)) + .build().apply { addListener(object : Player.Listener { override fun onIsPlayingChanged(playing: Boolean) { _isPlaying.value = playing diff --git a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt index 64776fa..9881788 100644 --- a/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt +++ b/app/src/main/java/com/radiola/ui/recordings/RecordingPlayerSheet.kt @@ -2,9 +2,14 @@ package com.radiola.ui.recordings import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -54,6 +59,7 @@ fun RecordingPlayerSheet( Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp) .padding(bottom = 40.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -202,6 +208,78 @@ fun RecordingPlayerSheet( ) } } + + // Список треков записи с переходом по тайм-коду + if (recording.markers.isNotEmpty()) { + Spacer(modifier = Modifier.height(28.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Треки в записи", + style = MaterialTheme.typography.titleSmall, + color = colors.textPrimary, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "${recording.markers.size}", + style = MaterialTheme.typography.labelMedium, + color = colors.textMuted + ) + } + Spacer(modifier = Modifier.height(8.dp)) + // Индекс текущего трека: последняя метка, до которой уже дошло время + val activeIndex = recording.markers.indexOfLast { positionMs >= it.offsetMs } + recording.markers.forEachIndexed { index, marker -> + MarkerRow( + timecode = formatMs(marker.offsetMs), + title = marker.title, + active = index == activeIndex, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.seekTo(marker.offsetMs) + } + ) + } + } + } +} + +/** Строка трека в записи: тайм-код + название, тап → переход. */ +@Composable +private fun MarkerRow( + timecode: String, + title: String, + active: Boolean, + onClick: () -> Unit +) { + val colors = RadiolaTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (active) colors.surface2 else androidx.compose.ui.graphics.Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = timecode, + style = MaterialTheme.typography.labelMedium, + color = if (active) colors.accent else colors.textMuted, + fontWeight = FontWeight.Medium + ) + Text( + text = title.ifBlank { "—" }, + style = MaterialTheme.typography.bodyMedium, + color = if (active) colors.accent else colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) } }