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:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user