From 848f5919c829783da63ef14329dcb5beddafa89e Mon Sep 17 00:00:00 2001 From: David Madl Date: Fri, 15 May 2026 09:03:20 +0200 Subject: [PATCH] feat: beat annotator --- app/build.gradle.kts | 1 + .../at/lockstep/player/LockstepViewModel.kt | 54 ++++ .../player/data/UserPreferencesRepository.kt | 13 + .../at/lockstep/player/data/db/PairingDao.kt | 8 + .../player/playback/PlaybackService.kt | 256 +++++++++++++++--- .../player/playback/TrackBoundaryEvent.kt | 23 ++ .../at/lockstep/player/ui/AnnotationScreen.kt | 200 ++++++++++++++ .../lockstep/player/ui/LockstepAppNavHost.kt | 23 +- .../lockstep/player/ui/navigation/Routes.kt | 3 + .../player/ui/settings/SettingsScreen.kt | 31 +++ .../player/util/BeatAnnotationStorage.kt | 63 +++++ app/src/main/res/values/strings.xml | 7 + 12 files changed, 640 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/at/lockstep/player/playback/TrackBoundaryEvent.kt create mode 100644 app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt create mode 100644 app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dcaee3b..d37d58e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.media:media:1.7.0") + implementation("androidx.media3:media3-exoplayer:1.5.1") implementation("androidx.browser:browser:1.8.0") implementation("androidx.documentfile:documentfile:1.0.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 8eb0ab0..9ec592d 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -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 = + prefs.annotationMode.stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + + fun setAnnotationMode(enabled: Boolean) { + viewModelScope.launch { + prefs.setAnnotationMode(enabled) + } + } + private val context get() = getApplication() /** @@ -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, + ) { + 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()) { diff --git a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt index 61a4239..ecc0f35 100644 --- a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt +++ b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt @@ -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 = + 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") } } diff --git a/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt b/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt index ef9e8a3..9caa00e 100644 --- a/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt +++ b/app/src/main/java/at/lockstep/player/data/db/PairingDao.kt @@ -24,6 +24,14 @@ interface PairingDao { @Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId") suspend fun listForPlaylist(playlistId: String): List + @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) } diff --git a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt index 1229ef8..4082cbd 100644 --- a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt +++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt @@ -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 = _uiState.asStateFlow() + private val _trackBoundary = + MutableSharedFlow( + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val trackBoundaryEvents: SharedFlow = _trackBoundary.asSharedFlow() + private var queue: List = 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" diff --git a/app/src/main/java/at/lockstep/player/playback/TrackBoundaryEvent.kt b/app/src/main/java/at/lockstep/player/playback/TrackBoundaryEvent.kt new file mode 100644 index 0000000..a650a6c --- /dev/null +++ b/app/src/main/java/at/lockstep/player/playback/TrackBoundaryEvent.kt @@ -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, +} diff --git a/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt b/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt new file mode 100644 index 0000000..592b201 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/ui/AnnotationScreen.kt @@ -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() } + + 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt index e879dae..bef989c 100644 --- a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt +++ b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt @@ -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(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() }, + ) + } } } diff --git a/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt b/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt index 1d2e965..0f5c82f 100644 --- a/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt +++ b/app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt @@ -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" } diff --git a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt index a7be2ae..f6959b0 100644 --- a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt new file mode 100644 index 0000000..cdee46b --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt @@ -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, + ): 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 ?: "" + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9de33c5..5e6b873 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,13 @@ More controls will land here in a later milestone. Sign out of Spotify Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server. + Annotation mode + When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing. + Beat annotation + Tap on each beat; times use the same clock as playback (ExoPlayer position). + Tap here on the beat + Beats recorded: %1$d + %1$d ms Pair local MP3s Choose folder of MP3s