3 Commits

18 changed files with 980 additions and 174 deletions

View File

@@ -1,5 +1,7 @@
# Bugs # Bugs
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already - syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
- "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next." - "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next."

View File

@@ -84,6 +84,7 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.media:media:1.7.0") implementation("androidx.media:media:1.7.0")
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.browser:browser:1.8.0") implementation("androidx.browser:browser:1.8.0")
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

View File

@@ -10,7 +10,10 @@ import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.data.UserPreferencesRepository import at.lockstep.player.data.UserPreferencesRepository
import at.lockstep.player.data.db.TrackPairingEntity import at.lockstep.player.data.db.TrackPairingEntity
import at.lockstep.player.util.AudioUriValidator 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.FolderMp3Scanner
import at.lockstep.player.util.MediaStoreMp3Scanner
import at.lockstep.player.util.Mp3EmbeddedMetadata import at.lockstep.player.util.Mp3EmbeddedMetadata
import at.lockstep.player.util.Mp3FolderCandidate import at.lockstep.player.util.Mp3FolderCandidate
import at.lockstep.player.util.TrackFileMatching import at.lockstep.player.util.TrackFileMatching
@@ -56,6 +59,19 @@ class LockstepViewModel(
null, 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>() private val context get() = getApplication<Application>()
/** /**
@@ -120,6 +136,45 @@ class LockstepViewModel(
suspend fun hasPairedTracks(playlistId: String): Boolean = pairingDao.countPaired(playlistId) > 0 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? { suspend fun syncJukeboxIfToken(): String? {
val token = spotifyAccessToken.value val token = spotifyAccessToken.value
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
@@ -239,6 +294,46 @@ class LockstepViewModel(
} }
val tracks = loadJukeboxTracksEnsuringDetail(playlistId) val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
Log.d(TAG, "pairPlaylistFromFolder scan returned mp3Count=${mp3s.size}")
val p =
mp3s.map { uri ->
val (id3Title, id3Artist) = Mp3EmbeddedMetadata.readTitleAndArtist(context, uri)
Mp3FolderCandidate(
uri = uri,
fileBaseName = mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
id3Title = id3Title,
id3Artist = id3Artist,
)
}.toMutableList()
Pair(mp3s.size, p)
}
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
/** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */
fun pairPlaylistFromDeviceAudio(
playlistId: String,
onFinished: (FolderPairingResult) -> Unit,
) {
viewModelScope.launch {
Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId")
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val candidates = MediaStoreMp3Scanner.listMp3Candidates(context)
Pair(candidates.size, candidates.toMutableList())
}
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
private fun logJukeboxTracks(tracks: List<TrackRow>) {
val withId = tracks.count { !it.trackId.isNullOrBlank() } val withId = tracks.count { !it.trackId.isNullOrBlank() }
val withTitle = tracks.count { !it.trackName.isNullOrBlank() } val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
val withArtist = tracks.count { !it.artistName.isNullOrBlank() } val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
@@ -257,36 +352,23 @@ class LockstepViewModel(
if (tracks.size > 12) { if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log") Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
} }
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
Log.d(TAG, "pairPlaylistFromFolder scan returned mp3Count=${mp3s.size}")
val p =
mp3s.map { uri ->
val (id3Title, id3Artist) = Mp3EmbeddedMetadata.readTitleAndArtist(context, uri)
Mp3FolderCandidate(
uri = uri,
fileBaseName = mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
id3Title = id3Title,
id3Artist = id3Artist,
)
}.toMutableList()
Pair(mp3s.size, p)
} }
private suspend fun matchPlaylistTracks(
playlistId: String,
tracks: List<TrackRow>,
pool: MutableList<Mp3FolderCandidate>,
mp3Total: Int,
): FolderPairingResult {
if (pool.isEmpty()) { if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair") Log.w(TAG, "No MP3 candidates after scan — cannot pair")
onFinished( return FolderPairingResult(
FolderPairingResult(
paired = 0, paired = 0,
failed = 0, failed = 0,
jukeboxRowCount = tracks.size, jukeboxRowCount = tracks.size,
mp3Count = mp3Total, mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0, skippedNoSpotifyTrackId = 0,
),
) )
return@launch
} }
Log.d( Log.d(
@@ -331,18 +413,15 @@ class LockstepViewModel(
} }
Log.d( Log.d(
TAG, TAG,
"pairPlaylistFromFolder done paired=$paired failed=$failed " + "playlist pairing done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total", "skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
) )
onFinished( return FolderPairingResult(
FolderPairingResult(
paired = paired, paired = paired,
failed = failed, failed = failed,
jukeboxRowCount = tracks.size, jukeboxRowCount = tracks.size,
mp3Count = mp3Total, mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId, skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
),
) )
} }
} }
}

View File

@@ -27,6 +27,12 @@ class UserPreferencesRepository(
prefs[KEY_SPOTIFY_ACCESS_TOKEN] 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) { suspend fun setOnboardingComplete(done: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[KEY_ONBOARDING_COMPLETE] = done 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 { companion object {
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token") private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
} }
} }

View File

@@ -24,6 +24,14 @@ interface PairingDao {
@Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId") @Query("SELECT * FROM track_pairings WHERE playlistId = :playlistId")
suspend fun listForPlaylist(playlistId: String): List<TrackPairingEntity> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: TrackPairingEntity) suspend fun upsert(row: TrackPairingEntity)
} }

View File

@@ -13,18 +13,34 @@ import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle 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.LockstepApplication
import at.lockstep.player.MainActivity import at.lockstep.player.MainActivity
import at.lockstep.player.R import at.lockstep.player.R
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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() { class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
@@ -32,12 +48,66 @@ class PlaybackService : Service() {
private val binder = LocalBinder() private val binder = LocalBinder()
private var player: ExoPlayer? = null
private var positionPollJob: Job? = null
private val _uiState = MutableStateFlow(PlaybackUiState.initial()) private val _uiState = MutableStateFlow(PlaybackUiState.initial())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow() 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 queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0 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() { inner class LocalBinder : Binder() {
fun getService(): PlaybackService = this@PlaybackService fun getService(): PlaybackService = this@PlaybackService
} }
@@ -65,6 +135,12 @@ class PlaybackService : Service() {
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
skipDelta(-1) skipDelta(-1)
} }
override fun onSeekTo(pos: Long) {
val p = player ?: return
p.seekTo(pos)
updateProgressFromPlayer()
}
}, },
) )
mediaSession.isActive = true 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) { private fun startPlaylist(pid: String) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val rows = app.playlistRepository.getTracks(pid) val rows = app.playlistRepository.getTracks(pid)
@@ -128,45 +238,66 @@ class PlaybackService : Service() {
if (uriStr.isNullOrBlank() || pairing.pairingError != null) { if (uriStr.isNullOrBlank() || pairing.pairingError != null) {
return@mapNotNull null return@mapNotNull null
} }
val hint =
row.durationMs?.takeIf { it > 0 }
?: DEFAULT_DURATION_HINT_MS
TrackQueueItem( TrackQueueItem(
id = tid, id = tid,
title = row.trackName ?: "", title = row.trackName ?: "",
artist = row.artistName ?: "", artist = row.artistName ?: "",
localUri = Uri.parse(uriStr), localUri = Uri.parse(uriStr),
durationMsHint = hint,
) )
} }
index = 0 index = 0
if (queue.isEmpty()) { if (queue.isEmpty()) {
withContext(Dispatchers.Main) {
releasePlayer()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
}
return@launch return@launch
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
ensurePlayer()
setPlaying(true) setPlaying(true)
publishCurrentTrack() publishCurrentTrack()
refreshForegroundNotification()
} }
} }
} }
private fun publishCurrentTrack() { private fun publishCurrentTrack() {
val item = queue.getOrNull(index) ?: return 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 = _uiState.value =
PlaybackUiState( PlaybackUiState(
title = item.title, title = item.title,
artist = item.artist, artist = item.artist,
progress = _uiState.value.progress, progress = 0f,
durationSeconds = durationSec, durationSeconds = durationSec,
isPlaying = _uiState.value.isPlaying, isPlaying = _uiState.value.isPlaying,
currentTrackId = item.id,
currentQueueIndex = index,
queueSize = queue.size,
) )
updateSessionMetadata( updateProgressFromPlayer()
item, updateSessionMetadata(item, durationSec)
durationSec, updatePlaybackStateFromPlayer()
)
updatePlaybackState()
refreshForegroundNotification() 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( private fun updateSessionMetadata(
item: TrackQueueItem, item: TrackQueueItem,
durationSec: Int, 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 = val actions =
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
val state = val state =
if (_uiState.value.isPlaying) { if (_uiState.value.isPlaying && p?.isPlaying == true) {
PlaybackStateCompat.STATE_PLAYING PlaybackStateCompat.STATE_PLAYING
} else { } else {
PlaybackStateCompat.STATE_PAUSED PlaybackStateCompat.STATE_PAUSED
@@ -195,14 +359,15 @@ class PlaybackService : Service() {
mediaSession.setPlaybackState( mediaSession.setPlaybackState(
PlaybackStateCompat.Builder() PlaybackStateCompat.Builder()
.setActions(actions) .setActions(actions)
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f) .setState(state, positionMs, 1f)
.build(), .build(),
) )
} }
private fun setPlaying(playing: Boolean) { private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing) _uiState.value = _uiState.value.copy(isPlaying = playing)
updatePlaybackState() player?.playWhenReady = playing
updatePlaybackStateFromPlayer()
refreshForegroundNotification() refreshForegroundNotification()
} }
@@ -212,8 +377,16 @@ class PlaybackService : Service() {
} }
private fun skipDelta(delta: Int) { private fun skipDelta(delta: Int) {
if (queue.isEmpty()) return if (queue.isEmpty()) {
index = (index + delta).coerceIn(0, queue.lastIndex) 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() publishCurrentTrack()
} }
@@ -231,31 +404,19 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) { fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return if (queue.isEmpty()) return
_uiState.value = val p = player ?: return
_uiState.value.copy( val durationMs = currentDurationMs()
progress = fraction.coerceIn(0f, 1f), val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
) p.seekTo(positionMs)
mediaSession.setPlaybackState( updateProgressFromPlayer()
PlaybackStateCompat.Builder() }
.setActions(
PlaybackStateCompat.ACTION_PLAY or /**
PlaybackStateCompat.ACTION_PAUSE or * Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or * May be called from any thread per Media3 [Player] contract.
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or */
PlaybackStateCompat.ACTION_SEEK_TO, fun getPlaybackPositionMs(): Long {
) return player?.currentPosition ?: 0L
.setState(
if (_uiState.value.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
},
(_uiState.value.progress * _uiState.value.durationSeconds * 1000).toLong(),
1f,
)
.build(),
)
refreshForegroundNotification()
} }
private fun refreshForegroundNotification() { private fun refreshForegroundNotification() {
@@ -332,6 +493,8 @@ class PlaybackService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
positionPollJob?.cancel()
releasePlayer()
mediaSession.run { mediaSession.run {
isActive = false isActive = false
release() release()
@@ -345,6 +508,9 @@ class PlaybackService : Service() {
val progress: Float, val progress: Float,
val durationSeconds: Int, val durationSeconds: Int,
val isPlaying: Boolean, val isPlaying: Boolean,
val currentTrackId: String?,
val currentQueueIndex: Int,
val queueSize: Int,
) { ) {
companion object { companion object {
fun initial() = fun initial() =
@@ -354,6 +520,9 @@ class PlaybackService : Service() {
progress = 0f, progress = 0f,
durationSeconds = 180, durationSeconds = 180,
isPlaying = false, isPlaying = false,
currentTrackId = null,
currentQueueIndex = 0,
queueSize = 0,
) )
} }
} }
@@ -363,9 +532,14 @@ class PlaybackService : Service() {
val title: String, val title: String,
val artist: String, val artist: String,
val localUri: Uri?, val localUri: Uri?,
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
val durationMsHint: Int,
) )
companion object { 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_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"
const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE" const val ACTION_TOGGLE_PAUSE = "at.lockstep.player.action.TOGGLE_PAUSE"
const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT" const val ACTION_SKIP_NEXT = "at.lockstep.player.action.SKIP_NEXT"

View File

@@ -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,
}

View 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,
)
}
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -32,6 +33,7 @@ fun LockstepAppNavHost(
navController: NavHostController, navController: NavHostController,
viewModel: LockstepViewModel, viewModel: LockstepViewModel,
) { ) {
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
var playback by remember { mutableStateOf<PlaybackService?>(null) } var playback by remember { mutableStateOf<PlaybackService?>(null) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -70,8 +72,12 @@ fun LockstepAppNavHost(
navController.navigate(Routes.pairing(playlist.id)) navController.navigate(Routes.pairing(playlist.id))
} else { } else {
startPlaylistPlayback(activity, playlist.id) startPlaylistPlayback(activity, playlist.id)
if (annotationMode) {
navController.navigate(Routes.annotation(playlist.id))
} else {
navController.navigate(Routes.nowPlaying(playlist.id)) navController.navigate(Routes.nowPlaying(playlist.id))
} }
}
}, },
onOpenSettings = { onOpenSettings = {
navController.navigate(Routes.Settings) navController.navigate(Routes.Settings)
@@ -112,6 +118,21 @@ fun LockstepAppNavHost(
onBack = { navController.popBackStack() }, 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() },
)
}
} }
} }

View File

@@ -6,8 +6,11 @@ object Routes {
const val Settings = "settings" const val Settings = "settings"
const val Pairing = "pairing/{playlistId}" const val Pairing = "pairing/{playlistId}"
const val NowPlaying = "nowPlaying/{playlistId}" const val NowPlaying = "nowPlaying/{playlistId}"
const val Annotation = "annotation/{playlistId}"
fun pairing(playlistId: String) = "pairing/$playlistId" fun pairing(playlistId: String) = "pairing/$playlistId"
fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId" fun nowPlaying(playlistId: String) = "nowPlaying/$playlistId"
fun annotation(playlistId: String) = "annotation/$playlistId"
} }

View File

@@ -1,5 +1,6 @@
package at.lockstep.player.ui.pairing package at.lockstep.player.ui.pairing
import android.content.pm.PackageManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -33,10 +35,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.jukebox.db.TrackRow import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.FolderPairingResult
import at.lockstep.player.LockstepViewModel import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R import at.lockstep.player.R
import at.lockstep.player.util.AudioReadPermission
import at.lockstep.player.util.SafInitialUris
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -78,54 +84,44 @@ fun PairingScreen(
onResult = { tree -> onResult = { tree ->
if (tree != null) { if (tree != null) {
viewModel.pairPlaylistFromFolder(playlistId, tree) { r -> viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
when { showPairingResultToast(context, r)
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
} }
} }
}, },
) )
val runDeviceScan = {
viewModel.pairPlaylistFromDeviceAudio(playlistId) { r ->
showPairingResultToast(context, r)
}
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
runDeviceScan()
} else {
Toast
.makeText(
context,
context.getString(R.string.pairing_audio_permission_denied),
Toast.LENGTH_LONG,
).show()
}
},
)
fun scanDeviceAudio() {
val permission = AudioReadPermission.permissionName()
when {
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ->
runDeviceScan()
else -> audioPermissionLauncher.launch(permission)
}
}
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
@@ -149,7 +145,22 @@ fun PairingScreen(
) { ) {
item { item {
Button( Button(
onClick = { openTree.launch(null) }, onClick = { scanDeviceAudio() },
modifier = Modifier.fillMaxWidth(),
) {
Text(context.getString(R.string.pairing_scan_device))
}
}
item {
Text(
text = context.getString(R.string.pairing_scan_device_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
OutlinedButton(
onClick = { openTree.launch(SafInitialUris.internalDocuments(context)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(context.getString(R.string.pairing_choose_folder)) Text(context.getString(R.string.pairing_choose_folder))
@@ -215,3 +226,52 @@ fun PairingScreen(
} }
} }
} }
private fun showPairingResultToast(
context: android.content.Context,
r: FolderPairingResult,
) {
when {
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
}

View File

@@ -2,7 +2,9 @@ package at.lockstep.player.ui.settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.player.LockstepViewModel import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R import at.lockstep.player.R
@@ -30,6 +36,7 @@ fun SettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
@@ -51,6 +58,30 @@ fun SettingsScreen(
.padding(24.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.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(
text = context.getString(R.string.settings_stub_body), text = context.getString(R.string.settings_stub_body),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,

View File

@@ -0,0 +1,13 @@
package at.lockstep.player.util
import android.Manifest
import android.os.Build
object AudioReadPermission {
fun permissionName(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}

View File

@@ -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 ?: ""
}
}
}

View File

@@ -0,0 +1,60 @@
package at.lockstep.player.util
import android.content.ContentUris
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import android.util.Log
object MediaStoreMp3Scanner {
private const val TAG = "LockstepPairing"
fun listMp3Candidates(context: Context): List<Mp3FolderCandidate> {
val resolver = context.applicationContext.contentResolver
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
@Suppress("DEPRECATION")
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
val projection =
arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
)
val selection =
"(${MediaStore.Audio.Media.MIME_TYPE} = ? OR " +
"${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?)"
val selectionArgs = arrayOf("audio/mpeg", "%.mp3")
val out = mutableListOf<Mp3FolderCandidate>()
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val displayName = cursor.getString(nameCol).orEmpty()
val title = cursor.getString(titleCol)?.trim()?.takeIf { it.isNotEmpty() }
val artist = cursor.getString(artistCol)?.trim()?.takeIf { it.isNotEmpty() }
out.add(
Mp3FolderCandidate(
uri = ContentUris.withAppendedId(collection, id),
fileBaseName = displayName.substringBeforeLast('.'),
id3Title = title,
id3Artist = artist,
),
)
}
} ?: Log.w(TAG, "MediaStore audio query returned null")
Log.d(TAG, "MediaStoreMp3Scanner found mp3Count=${out.size}")
return out.distinctBy { it.uri }.sortedBy { it.fileBaseName.lowercase() }
}
}

View File

@@ -0,0 +1,41 @@
package at.lockstep.player.util
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
object SafInitialUris {
private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
private const val INITIAL_URI_EXTRA = "android.provider.extra.INITIAL_URI"
/**
* Internal-storage Documents. Uses [StorageManager] on Android 10+ so the system picker
* lands in a choosable folder instead of the blocked volume root on Pixel devices.
*/
fun internalDocuments(context: Context): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val storageManager = context.getSystemService(StorageManager::class.java)
val intent = storageManager.primaryStorageVolume.createOpenDocumentTreeIntent()
val root =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(INITIAL_URI_EXTRA, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(INITIAL_URI_EXTRA)
}
if (root != null) {
val scheme =
root
.toString()
.replace("/root/", "/document/") + "%3A" + "Documents"
return Uri.parse(scheme)
}
}
return DocumentsContract.buildDocumentUri(
EXTERNAL_STORAGE_AUTHORITY,
"primary:Documents",
)
}
}

View File

@@ -28,9 +28,19 @@
<string name="settings_stub_body">More controls will land here in a later milestone.</string> <string name="settings_stub_body">More controls will land here in a later milestone.</string>
<string name="settings_logout_spotify">Sign out of Spotify</string> <string name="settings_logout_spotify">Sign out of Spotify</string>
<string name="settings_logout_spotify_help">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.</string> <string name="settings_logout_spotify_help">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.</string>
<string name="settings_annotation_mode">Annotation mode</string>
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
<string name="annotation_title">Beat annotation</string>
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
<string name="annotation_tap_area_label">Tap here on the beat</string>
<string name="annotation_beat_count">Beats recorded: %1$d</string>
<string name="annotation_time_ms">%1$d ms</string>
<string name="pairing_title">Pair local MP3s</string> <string name="pairing_title">Pair local MP3s</string>
<string name="pairing_choose_folder">Choose folder of MP3s</string> <string name="pairing_choose_folder">Choose folder of MP3s</string>
<string name="pairing_scan_device">Scan audio on this device</string>
<string name="pairing_scan_device_help">Finds MP3s in Music and other folders without using the folder picker.</string>
<string name="pairing_audio_permission_denied">Audio permission is required to scan MP3s on this device.</string>
<string name="pairing_status_paired">Paired with local file</string> <string name="pairing_status_paired">Paired with local file</string>
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string> <string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string> <string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>