feat(recordings): перемотка записей + тайм-коды треков

1) Перемотка: записи эфира — сырой ADTS-AAC/MP3 без индексов, ExoPlayer
   считал их неперематываемыми (старт всегда с нуля). Включён CBR-seeking
   (DefaultExtractorsFactory.setConstantBitrateSeekingEnabled) — seek работает.

2) Тайм-коды треков: при записи фиксируются смены now-playing с offset от
   начала (модель TrackMarker, колонка markers в recordings, миграция v6,
   захват через NowPlayingRepository — свой поллинг, не зависит от экрана).
   В плеере записи — список «Треки в записи»: тайм-код + название, тап
   переходит к моменту, текущий трек подсвечен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 13:18:23 +03:00
parent 777f5d5082
commit fc63814f97
8 changed files with 172 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Boolean> = _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<TrackMarker>()
track?.let {
if (it.artist.isNotBlank() || it.song.isNotBlank()) {
markers.add(TrackMarker(0L, it.artist, it.song))
}
}
markerJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
if (markers.isNotEmpty()) {
try { db.recordingDao().updateMarkers(id, encodeMarkers(markers)) } catch (_: Exception) {}
}
// Свой поллинг now-playing — чтобы метки писались независимо от экрана радио
launch {
while (isActive) {
try { nowPlayingRepository.refreshNowPlaying() } catch (_: Exception) {}
delay(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<TrackMarker>): String =
list.joinToString("\n") { "${it.offsetMs}\t${it.artist}\t${it.song}" }
private fun decodeMarkers(raw: String): List<TrackMarker> {
if (raw.isBlank()) return emptyList()
return raw.split("\n").mapNotNull { line ->
val p = line.split("\t")
if (p.size != 3) return@mapNotNull null
val off = p[0].toLongOrNull() ?: return@mapNotNull null
TrackMarker(off, p[1], p[2])
}
}
}

View File

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

View File

@@ -8,5 +8,17 @@ data class Recording(
val startTime: Long,
val endTime: Long?,
val trackName: String?,
val duration: Long?
val duration: Long?,
// Тайм-коды треков, звучавших во время записи (для навигации при прослушивании).
val markers: List<TrackMarker> = emptyList()
)
/** Отметка трека в записи: смещение от начала записи + что играло. */
data class TrackMarker(
val offsetMs: Long,
val artist: String,
val song: String
) {
val title: String
get() = listOf(artist, song).filter { it.isNotBlank() }.joinToString("")
}

View File

@@ -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<Long> = _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

View File

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