feat: beat annotator
This commit is contained in:
@@ -10,6 +10,8 @@ import at.lockstep.jukebox.db.TrackRow
|
||||
import at.lockstep.player.data.UserPreferencesRepository
|
||||
import at.lockstep.player.data.db.TrackPairingEntity
|
||||
import at.lockstep.player.util.AudioUriValidator
|
||||
import at.lockstep.player.playback.TrackBoundaryEvent
|
||||
import at.lockstep.player.util.BeatAnnotationStorage
|
||||
import at.lockstep.player.util.FolderMp3Scanner
|
||||
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
||||
import at.lockstep.player.util.Mp3FolderCandidate
|
||||
@@ -56,6 +58,19 @@ class LockstepViewModel(
|
||||
null,
|
||||
)
|
||||
|
||||
val annotationMode: StateFlow<Boolean> =
|
||||
prefs.annotationMode.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
|
||||
fun setAnnotationMode(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAnnotationMode(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private val context get() = getApplication<Application>()
|
||||
|
||||
/**
|
||||
@@ -120,6 +135,45 @@ class LockstepViewModel(
|
||||
|
||||
suspend fun hasPairedTracks(playlistId: String): Boolean = pairingDao.countPaired(playlistId) > 0
|
||||
|
||||
suspend fun getPlaylistDisplayName(playlistId: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
app.playlistRepository.getPlaylists()
|
||||
.find { it.id == playlistId }
|
||||
?.name
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifBlank { "playlist" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described
|
||||
* by [event]. Skips when [beatTimesMs] is empty.
|
||||
*/
|
||||
fun persistBeatAnnotation(
|
||||
playlistId: String,
|
||||
playlistDisplayName: String,
|
||||
event: TrackBoundaryEvent,
|
||||
beatTimesMs: List<Long>,
|
||||
) {
|
||||
if (beatTimesMs.isEmpty()) {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
||||
val contentId = docId.ifBlank { event.trackId }
|
||||
BeatAnnotationStorage.writeAnnotationsFile(
|
||||
context = getApplication(),
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
contentId = contentId,
|
||||
title = event.title,
|
||||
artist = event.artist,
|
||||
beatTimesMs = beatTimesMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncJukeboxIfToken(): String? {
|
||||
val token = spotifyAccessToken.value
|
||||
if (token.isNullOrBlank()) {
|
||||
|
||||
@@ -27,6 +27,12 @@ class UserPreferencesRepository(
|
||||
prefs[KEY_SPOTIFY_ACCESS_TOKEN]
|
||||
}
|
||||
|
||||
/** When true, opening a paired playlist navigates to beat annotation instead of Now Playing. */
|
||||
val annotationMode: Flow<Boolean> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[KEY_ANNOTATION_MODE] == true
|
||||
}
|
||||
|
||||
suspend fun setOnboardingComplete(done: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ONBOARDING_COMPLETE] = done
|
||||
@@ -43,8 +49,15 @@ class UserPreferencesRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAnnotationMode(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ANNOTATION_MODE] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
|
||||
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
|
||||
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ interface PairingDao {
|
||||
@Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
|
||||
suspend fun listForPlaylist(playlistId: String): List<TrackPairingEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM track_pairings WHERE playlistId = :playlistId AND trackId = :trackId LIMIT 1",
|
||||
)
|
||||
suspend fun findForTrack(
|
||||
playlistId: String,
|
||||
trackId: String,
|
||||
): TrackPairingEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(row: TrackPairingEntity)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,34 @@ import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import at.lockstep.player.LockstepApplication
|
||||
import at.lockstep.player.MainActivity
|
||||
import at.lockstep.player.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use
|
||||
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is
|
||||
* the same timeline used for seeking and end-of-track reporting — see Media3
|
||||
* [Player] / [ExoPlayer] documentation.
|
||||
*/
|
||||
class PlaybackService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
@@ -32,12 +48,66 @@ class PlaybackService : Service() {
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
private var positionPollJob: Job? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
|
||||
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _trackBoundary =
|
||||
MutableSharedFlow<TrackBoundaryEvent>(
|
||||
extraBufferCapacity = 32,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val trackBoundaryEvents: SharedFlow<TrackBoundaryEvent> = _trackBoundary.asSharedFlow()
|
||||
|
||||
private var queue: List<TrackQueueItem> = emptyList()
|
||||
private var index: Int = 0
|
||||
|
||||
private val playerListener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
if (queue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (index < queue.lastIndex) {
|
||||
emitTrackBoundaryForQueueIndex(
|
||||
index,
|
||||
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
|
||||
)
|
||||
index++
|
||||
publishCurrentTrack()
|
||||
} else {
|
||||
emitTrackBoundaryForQueueIndex(
|
||||
index,
|
||||
TrackBoundaryReason.LAST_TRACK_FINISHED,
|
||||
)
|
||||
setPlaying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitTrackBoundaryForQueueIndex(
|
||||
i: Int,
|
||||
reason: TrackBoundaryReason,
|
||||
) {
|
||||
val item = queue.getOrNull(i) ?: return
|
||||
scope.launch {
|
||||
_trackBoundary.emit(
|
||||
TrackBoundaryEvent(
|
||||
trackId = item.id,
|
||||
title = item.title,
|
||||
artist = item.artist,
|
||||
queueIndex = i,
|
||||
queueSize = queue.size,
|
||||
reason = reason,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): PlaybackService = this@PlaybackService
|
||||
}
|
||||
@@ -65,6 +135,12 @@ class PlaybackService : Service() {
|
||||
override fun onSkipToPrevious() {
|
||||
skipDelta(-1)
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
val p = player ?: return
|
||||
p.seekTo(pos)
|
||||
updateProgressFromPlayer()
|
||||
}
|
||||
},
|
||||
)
|
||||
mediaSession.isActive = true
|
||||
@@ -115,6 +191,40 @@ class PlaybackService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePlayer(): ExoPlayer {
|
||||
player?.let {
|
||||
return it
|
||||
}
|
||||
return ExoPlayer.Builder(this)
|
||||
.build()
|
||||
.also {
|
||||
it.addListener(playerListener)
|
||||
player = it
|
||||
startPositionPolling()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPositionPolling() {
|
||||
positionPollJob?.cancel()
|
||||
positionPollJob =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(UPDATE_INTERVAL_MS)
|
||||
if (player != null && queue.isNotEmpty()) {
|
||||
updateProgressFromPlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
positionPollJob?.cancel()
|
||||
positionPollJob = null
|
||||
player?.removeListener(playerListener)
|
||||
player?.release()
|
||||
player = null
|
||||
}
|
||||
|
||||
private fun startPlaylist(pid: String) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val rows = app.playlistRepository.getTracks(pid)
|
||||
@@ -128,45 +238,66 @@ class PlaybackService : Service() {
|
||||
if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val hint =
|
||||
row.durationMs?.takeIf { it > 0 }
|
||||
?: DEFAULT_DURATION_HINT_MS
|
||||
TrackQueueItem(
|
||||
id = tid,
|
||||
title = row.trackName ?: "—",
|
||||
artist = row.artistName ?: "—",
|
||||
localUri = Uri.parse(uriStr),
|
||||
durationMsHint = hint,
|
||||
)
|
||||
}
|
||||
index = 0
|
||||
if (queue.isEmpty()) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
withContext(Dispatchers.Main) {
|
||||
releasePlayer()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
ensurePlayer()
|
||||
setPlaying(true)
|
||||
publishCurrentTrack()
|
||||
refreshForegroundNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishCurrentTrack() {
|
||||
val item = queue.getOrNull(index) ?: return
|
||||
val durationSec = 180
|
||||
applyCurrentMediaItem(item)
|
||||
val durationSec =
|
||||
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
|
||||
?: (item.durationMsHint / 1000).coerceAtLeast(1)
|
||||
_uiState.value =
|
||||
PlaybackUiState(
|
||||
title = item.title,
|
||||
artist = item.artist,
|
||||
progress = _uiState.value.progress,
|
||||
progress = 0f,
|
||||
durationSeconds = durationSec,
|
||||
isPlaying = _uiState.value.isPlaying,
|
||||
currentTrackId = item.id,
|
||||
currentQueueIndex = index,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updateSessionMetadata(
|
||||
item,
|
||||
durationSec,
|
||||
)
|
||||
updatePlaybackState()
|
||||
updateProgressFromPlayer()
|
||||
updateSessionMetadata(item, durationSec)
|
||||
updatePlaybackStateFromPlayer()
|
||||
refreshForegroundNotification()
|
||||
}
|
||||
|
||||
private fun applyCurrentMediaItem(item: TrackQueueItem) {
|
||||
val uri = item.localUri ?: return
|
||||
val p = ensurePlayer()
|
||||
p.setMediaItem(MediaItem.fromUri(uri))
|
||||
p.prepare()
|
||||
p.playWhenReady = _uiState.value.isPlaying
|
||||
}
|
||||
|
||||
private fun updateSessionMetadata(
|
||||
item: TrackQueueItem,
|
||||
durationSec: Int,
|
||||
@@ -180,14 +311,47 @@ class PlaybackService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePlaybackState() {
|
||||
private fun currentDurationMs(): Long {
|
||||
val p = player
|
||||
val fromPlayer = p?.duration?.takeIf { it > 0 }
|
||||
if (fromPlayer != null) {
|
||||
return fromPlayer
|
||||
}
|
||||
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
|
||||
return hint
|
||||
}
|
||||
|
||||
private fun updateProgressFromPlayer() {
|
||||
val p = player ?: return
|
||||
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
||||
val positionMs = p.currentPosition.coerceIn(0L, durationMs)
|
||||
if (queue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
progress = progress,
|
||||
durationSeconds = durationSec,
|
||||
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
||||
currentQueueIndex = index,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updatePlaybackStateFromPlayer()
|
||||
}
|
||||
|
||||
private fun updatePlaybackStateFromPlayer() {
|
||||
val p = player
|
||||
val positionMs = p?.currentPosition ?: 0L
|
||||
val actions =
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
val state =
|
||||
if (_uiState.value.isPlaying) {
|
||||
if (_uiState.value.isPlaying && p?.isPlaying == true) {
|
||||
PlaybackStateCompat.STATE_PLAYING
|
||||
} else {
|
||||
PlaybackStateCompat.STATE_PAUSED
|
||||
@@ -195,14 +359,15 @@ class PlaybackService : Service() {
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(actions)
|
||||
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
|
||||
.setState(state, positionMs, 1f)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPlaying(playing: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isPlaying = playing)
|
||||
updatePlaybackState()
|
||||
player?.playWhenReady = playing
|
||||
updatePlaybackStateFromPlayer()
|
||||
refreshForegroundNotification()
|
||||
}
|
||||
|
||||
@@ -212,8 +377,16 @@ class PlaybackService : Service() {
|
||||
}
|
||||
|
||||
private fun skipDelta(delta: Int) {
|
||||
if (queue.isEmpty()) return
|
||||
index = (index + delta).coerceIn(0, queue.lastIndex)
|
||||
if (queue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val oldIndex = index
|
||||
val newIndex = (oldIndex + delta).coerceIn(0, queue.lastIndex)
|
||||
if (newIndex == oldIndex) {
|
||||
return
|
||||
}
|
||||
emitTrackBoundaryForQueueIndex(oldIndex, TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK)
|
||||
index = newIndex
|
||||
publishCurrentTrack()
|
||||
}
|
||||
|
||||
@@ -231,31 +404,19 @@ class PlaybackService : Service() {
|
||||
|
||||
fun requestSeek(fraction: Float) {
|
||||
if (queue.isEmpty()) return
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
progress = fraction.coerceIn(0f, 1f),
|
||||
)
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_SEEK_TO,
|
||||
)
|
||||
.setState(
|
||||
if (_uiState.value.isPlaying) {
|
||||
PlaybackStateCompat.STATE_PLAYING
|
||||
} else {
|
||||
PlaybackStateCompat.STATE_PAUSED
|
||||
},
|
||||
(_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(),
|
||||
1f,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
refreshForegroundNotification()
|
||||
val p = player ?: return
|
||||
val durationMs = currentDurationMs()
|
||||
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
|
||||
p.seekTo(positionMs)
|
||||
updateProgressFromPlayer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
|
||||
* May be called from any thread per Media3 [Player] contract.
|
||||
*/
|
||||
fun getPlaybackPositionMs(): Long {
|
||||
return player?.currentPosition ?: 0L
|
||||
}
|
||||
|
||||
private fun refreshForegroundNotification() {
|
||||
@@ -332,6 +493,8 @@ class PlaybackService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
positionPollJob?.cancel()
|
||||
releasePlayer()
|
||||
mediaSession.run {
|
||||
isActive = false
|
||||
release()
|
||||
@@ -345,6 +508,9 @@ class PlaybackService : Service() {
|
||||
val progress: Float,
|
||||
val durationSeconds: Int,
|
||||
val isPlaying: Boolean,
|
||||
val currentTrackId: String?,
|
||||
val currentQueueIndex: Int,
|
||||
val queueSize: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun initial() =
|
||||
@@ -354,6 +520,9 @@ class PlaybackService : Service() {
|
||||
progress = 0f,
|
||||
durationSeconds = 180,
|
||||
isPlaying = false,
|
||||
currentTrackId = null,
|
||||
currentQueueIndex = 0,
|
||||
queueSize = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -363,9 +532,14 @@ class PlaybackService : Service() {
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val localUri: Uri?,
|
||||
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
|
||||
val durationMsHint: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val UPDATE_INTERVAL_MS = 250L
|
||||
private const val DEFAULT_DURATION_HINT_MS = 180_000
|
||||
|
||||
const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
|
||||
const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
|
||||
const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package at.lockstep.player.playback
|
||||
|
||||
/**
|
||||
* Fired when annotation should flush beats for the track being left:
|
||||
* user skipped, auto-advance after a track ended, or the last track finished.
|
||||
*/
|
||||
data class TrackBoundaryEvent(
|
||||
val trackId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
/** 0-based index in the current play queue when this track was current. */
|
||||
val queueIndex: Int,
|
||||
val queueSize: Int,
|
||||
val reason: TrackBoundaryReason,
|
||||
)
|
||||
|
||||
enum class TrackBoundaryReason {
|
||||
/** Left this track for another (skip, previous, or middle track ended). */
|
||||
ADVANCED_TO_OTHER_TRACK,
|
||||
|
||||
/** Playback of the last track in the playlist completed. */
|
||||
LAST_TRACK_FINISHED,
|
||||
}
|
||||
200
app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt
Normal file
200
app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt
Normal file
@@ -0,0 +1,200 @@
|
||||
package at.lockstep.player.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.R
|
||||
import at.lockstep.player.playback.PlaybackService
|
||||
|
||||
/**
|
||||
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
|
||||
* timeline). JSON is written only when the track changes (skip/prev/next or natural advance) or when
|
||||
* the last track in the playlist finishes — see [PlaybackService.trackBoundaryEvents].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnnotationRoute(
|
||||
playlistId: String,
|
||||
playback: PlaybackService?,
|
||||
viewModel: LockstepViewModel,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var ui by remember {
|
||||
mutableStateOf(
|
||||
NowPlayingUiState(
|
||||
title = context.getString(R.string.now_playing_idle_title),
|
||||
artist = context.getString(R.string.now_playing_idle_artist),
|
||||
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
|
||||
progress = 0f,
|
||||
durationSeconds = 180,
|
||||
isPlaying = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
ui =
|
||||
NowPlayingUiState(
|
||||
title = p.title,
|
||||
artist = p.artist,
|
||||
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
|
||||
progress = p.progress,
|
||||
durationSeconds = p.durationSeconds,
|
||||
isPlaying = p.isPlaying,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val beatTimesMs = remember { mutableStateListOf<Long>() }
|
||||
|
||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback, playlistId, playlistDisplayName) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.trackBoundaryEvents.collect { event ->
|
||||
val snapshot = beatTimesMs.toList()
|
||||
beatTimesMs.clear()
|
||||
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = context.getString(R.string.annotation_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.annotation_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 420.dp)
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
NowPlayingScreen(
|
||||
state = ui,
|
||||
onProgressChange = { fraction ->
|
||||
playback?.requestSeek(fraction)
|
||||
ui = ui.copy(progress = fraction)
|
||||
},
|
||||
onPrevious = { playback?.requestSkipPrevious() },
|
||||
onTogglePlayPause = { playback?.requestTogglePause() },
|
||||
onNext = { playback?.requestSkipNext() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = context.getString(R.string.annotation_beat_count, beatTimesMs.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.pointerInput(playback) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
val t = playback?.getPlaybackPositionMs() ?: 0L
|
||||
beatTimesMs.add(t)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.annotation_tap_area_label),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 160.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
itemsIndexed(beatTimesMs) { i, ms ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${i + 1}.",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = context.getString(R.string.annotation_time_ms, ms.toInt()),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
@@ -32,6 +33,7 @@ fun LockstepAppNavHost(
|
||||
navController: NavHostController,
|
||||
viewModel: LockstepViewModel,
|
||||
) {
|
||||
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||
var playback by remember { mutableStateOf<PlaybackService?>(null) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -70,7 +72,11 @@ fun LockstepAppNavHost(
|
||||
navController.navigate(Routes.pairing(playlist.id))
|
||||
} else {
|
||||
startPlaylistPlayback(activity, playlist.id)
|
||||
navController.navigate(Routes.nowPlaying(playlist.id))
|
||||
if (annotationMode) {
|
||||
navController.navigate(Routes.annotation(playlist.id))
|
||||
} else {
|
||||
navController.navigate(Routes.nowPlaying(playlist.id))
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenSettings = {
|
||||
@@ -112,6 +118,21 @@ fun LockstepAppNavHost(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.Annotation,
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("playlistId") { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val playlistId = entry.arguments?.getString("playlistId").orEmpty()
|
||||
AnnotationRoute(
|
||||
playlistId = playlistId,
|
||||
playback = playback,
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ object Routes {
|
||||
const val Settings = "settings"
|
||||
const val Pairing = "pairing/{playlistId}"
|
||||
const val NowPlaying = "nowPlaying/{playlistId}"
|
||||
const val Annotation = "annotation/{playlistId}"
|
||||
|
||||
fun pairing(playlistId: String) = "pairing/$playlistId"
|
||||
|
||||
fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId"
|
||||
|
||||
fun annotation(playlistId: String) = "annotation/$playlistId"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package at.lockstep.player.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -13,12 +15,16 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import at.lockstep.player.LockstepViewModel
|
||||
import at.lockstep.player.R
|
||||
|
||||
@@ -30,6 +36,7 @@ fun SettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -51,6 +58,30 @@ fun SettingsScreen(
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) {
|
||||
Text(
|
||||
text = context.getString(R.string.settings_annotation_mode),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = context.getString(R.string.settings_annotation_mode_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = annotationMode,
|
||||
onCheckedChange = { viewModel.setAnnotationMode(it) },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = context.getString(R.string.settings_stub_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package at.lockstep.player.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
object BeatAnnotationStorage {
|
||||
|
||||
private const val DIR_NAME = "beat_annotations"
|
||||
|
||||
fun annotationsDir(context: Context): File =
|
||||
File(context.filesDir, DIR_NAME).apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json".
|
||||
*/
|
||||
fun writeAnnotationsFile(
|
||||
context: Context,
|
||||
playlistDisplayName: String,
|
||||
trackQueueIndex0Based: Int,
|
||||
contentId: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
beatTimesMs: List<Long>,
|
||||
): File {
|
||||
val safeName =
|
||||
playlistDisplayName
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
.trim()
|
||||
.ifBlank { "playlist" }
|
||||
.take(120)
|
||||
val suffix =
|
||||
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||
val file = File(annotationsDir(context), "${safeName}_$suffix.json")
|
||||
val sec = beatTimesMs.map { it / 1000.0 }
|
||||
val json =
|
||||
JSONObject().apply {
|
||||
put("contentId", contentId)
|
||||
put("title", title)
|
||||
put("artist", artist)
|
||||
put("beatTimesSec", JSONArray(sec))
|
||||
}
|
||||
file.writeText(json.toString(2))
|
||||
return file
|
||||
}
|
||||
|
||||
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
||||
fun mp3DocumentContentId(localUri: String?): String {
|
||||
if (localUri.isNullOrBlank()) {
|
||||
return ""
|
||||
}
|
||||
val u = Uri.parse(localUri)
|
||||
return try {
|
||||
DocumentsContract.getDocumentId(u)
|
||||
} catch (_: Exception) {
|
||||
u.lastPathSegment ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user