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