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(
|
@Database(
|
||||||
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class],
|
||||||
version = 5
|
version = 6
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun stationDao(): StationDao
|
abstract fun stationDao(): StationDao
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ interface RecordingDao {
|
|||||||
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
|
@Query("UPDATE recordings SET endTime = :endTime, duration = :duration WHERE id = :id")
|
||||||
suspend fun updateEndTime(id: Long, endTime: Long, duration: Long)
|
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")
|
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||||
suspend fun getById(id: Long): RecordingEntity?
|
suspend fun getById(id: Long): RecordingEntity?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,7 @@ data class RecordingEntity(
|
|||||||
val startTime: Long,
|
val startTime: Long,
|
||||||
val endTime: Long?,
|
val endTime: Long?,
|
||||||
val trackName: String?,
|
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.Recording
|
||||||
import com.radiola.domain.model.Station
|
import com.radiola.domain.model.Station
|
||||||
import com.radiola.domain.model.Track
|
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.domain.repository.RecordingRepository
|
||||||
import com.radiola.service.RecordingService
|
import com.radiola.service.RecordingService
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -28,6 +31,7 @@ import javax.inject.Inject
|
|||||||
class RecordingRepositoryImpl @Inject constructor(
|
class RecordingRepositoryImpl @Inject constructor(
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val nowPlayingRepository: NowPlayingRepository,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : RecordingRepository {
|
) : RecordingRepository {
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ class RecordingRepositoryImpl @Inject constructor(
|
|||||||
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
override val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||||
|
|
||||||
private var recordingJob: Job? = null
|
private var recordingJob: Job? = null
|
||||||
|
private var markerJob: Job? = null
|
||||||
private var currentCall: okhttp3.Call? = null
|
private var currentCall: okhttp3.Call? = null
|
||||||
private var currentRecordingId: Long? = null
|
private var currentRecordingId: Long? = null
|
||||||
|
|
||||||
@@ -74,6 +79,36 @@ class RecordingRepositoryImpl @Inject constructor(
|
|||||||
db.recordingDao().insert(entity)
|
db.recordingDao().insert(entity)
|
||||||
_isRecording.value = true
|
_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
|
// Start foreground service to keep process alive during recording
|
||||||
val serviceIntent = Intent(context, RecordingService::class.java).apply {
|
val serviceIntent = Intent(context, RecordingService::class.java).apply {
|
||||||
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
|
putExtra(RecordingService.EXTRA_STATION_NAME, station.name)
|
||||||
@@ -125,6 +160,8 @@ class RecordingRepositoryImpl @Inject constructor(
|
|||||||
currentCall = null
|
currentCall = null
|
||||||
recordingJob?.cancelAndJoin()
|
recordingJob?.cancelAndJoin()
|
||||||
recordingJob = null
|
recordingJob = null
|
||||||
|
markerJob?.cancel()
|
||||||
|
markerJob = null
|
||||||
_isRecording.value = false
|
_isRecording.value = false
|
||||||
|
|
||||||
// Stop foreground service
|
// Stop foreground service
|
||||||
@@ -158,6 +195,22 @@ class RecordingRepositoryImpl @Inject constructor(
|
|||||||
startTime = startTime,
|
startTime = startTime,
|
||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
trackName = trackName,
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_2_3
|
||||||
import com.radiola.data.local.MIGRATION_3_4
|
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.remote.AuthInterceptor
|
import com.radiola.data.remote.AuthInterceptor
|
||||||
import com.radiola.data.remote.LrcLibApi
|
import com.radiola.data.remote.LrcLibApi
|
||||||
import com.radiola.data.remote.LoveApi
|
import com.radiola.data.remote.LoveApi
|
||||||
@@ -137,7 +138,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)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -8,5 +8,17 @@ data class Recording(
|
|||||||
val startTime: Long,
|
val startTime: Long,
|
||||||
val endTime: Long?,
|
val endTime: Long?,
|
||||||
val trackName: String?,
|
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(" — ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.media3.common.C
|
|||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
import com.radiola.domain.model.Recording
|
import com.radiola.domain.model.Recording
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -41,7 +43,16 @@ class RecordingPlaybackController @Inject constructor(
|
|||||||
private val _durationMs = MutableStateFlow(0L)
|
private val _durationMs = MutableStateFlow(0L)
|
||||||
val durationMs: StateFlow<Long> = _durationMs
|
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 {
|
addListener(object : Player.Listener {
|
||||||
override fun onIsPlayingChanged(playing: Boolean) {
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
_isPlaying.value = playing
|
_isPlaying.value = playing
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ package com.radiola.ui.recordings
|
|||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
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.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -54,6 +59,7 @@ fun RecordingPlayerSheet(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
.padding(bottom = 40.dp),
|
.padding(bottom = 40.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user