Compare commits
7 Commits
7beea662b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb53d1e0ad | |||
| b5bc01fac2 | |||
| 698605d7a9 | |||
| c11ad041d7 | |||
| 4315944733 | |||
| eb61437f34 | |||
| 848f5919c8 |
2
BUGS.md
2
BUGS.md
@@ -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."
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
<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.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ 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.RunDataStorage
|
||||||
|
import at.lockstep.player.util.RunTrackDataSnapshot
|
||||||
import at.lockstep.player.util.TrackFileMatching
|
import at.lockstep.player.util.TrackFileMatching
|
||||||
import at.lockstep.player.util.mp3DisplayNameFromUri
|
import at.lockstep.player.util.mp3DisplayNameFromUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -56,6 +61,32 @@ class LockstepViewModel(
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val annotationMode: StateFlow<Boolean> =
|
||||||
|
prefs.annotationMode.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val collectRunData: StateFlow<Boolean> =
|
||||||
|
prefs.collectRunData.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setAnnotationMode(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setAnnotationMode(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCollectRunData(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setCollectRunData(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val context get() = getApplication<Application>()
|
private val context get() = getApplication<Application>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +151,105 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
|
||||||
|
* Skips when [samples] is empty or the track has no paired local URI.
|
||||||
|
*/
|
||||||
|
fun persistRunData(
|
||||||
|
playlistId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
runSessionFolder: String,
|
||||||
|
event: TrackBoundaryEvent,
|
||||||
|
snapshot: RunTrackDataSnapshot,
|
||||||
|
) {
|
||||||
|
if (snapshot.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||||
|
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
|
RunDataStorage.writeRunDataFile(
|
||||||
|
context = getApplication(),
|
||||||
|
runSessionFolder = runSessionFolder,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = event.queueIndex,
|
||||||
|
metaContentUri = meta,
|
||||||
|
title = event.title,
|
||||||
|
artist = event.artist,
|
||||||
|
snapshot = snapshot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush in-progress run data when leaving Now Playing before a track boundary fires. */
|
||||||
|
fun persistRunDataForCurrentTrack(
|
||||||
|
playlistId: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
runSessionFolder: String,
|
||||||
|
trackId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
queueIndex: Int,
|
||||||
|
snapshot: RunTrackDataSnapshot,
|
||||||
|
) {
|
||||||
|
if (snapshot.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val pairing = pairingDao.findForTrack(playlistId, trackId)
|
||||||
|
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
|
||||||
|
RunDataStorage.writeRunDataFile(
|
||||||
|
context = getApplication(),
|
||||||
|
runSessionFolder = runSessionFolder,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
trackQueueIndex0Based = queueIndex,
|
||||||
|
metaContentUri = meta,
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
snapshot = snapshot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun syncJukeboxIfToken(): String? {
|
suspend fun syncJukeboxIfToken(): String? {
|
||||||
val token = spotifyAccessToken.value
|
val token = spotifyAccessToken.value
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) {
|
||||||
@@ -239,25 +369,7 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||||
val withId = tracks.count { !it.trackId.isNullOrBlank() }
|
logJukeboxTracks(tracks)
|
||||||
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
|
|
||||||
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
|
|
||||||
"withTitle=$withTitle withArtist=$withArtist",
|
|
||||||
)
|
|
||||||
tracks.take(12).forEach { row ->
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
|
||||||
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (tracks.size > 12) {
|
|
||||||
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
|
||||||
}
|
|
||||||
|
|
||||||
val (mp3Total, pool) =
|
val (mp3Total, pool) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
|
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
|
||||||
@@ -274,75 +386,117 @@ class LockstepViewModel(
|
|||||||
}.toMutableList()
|
}.toMutableList()
|
||||||
Pair(mp3s.size, p)
|
Pair(mp3s.size, p)
|
||||||
}
|
}
|
||||||
|
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
|
||||||
if (pool.isEmpty()) {
|
|
||||||
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
|
|
||||||
onFinished(
|
|
||||||
FolderPairingResult(
|
|
||||||
paired = 0,
|
|
||||||
failed = 0,
|
|
||||||
jukeboxRowCount = tracks.size,
|
|
||||||
mp3Count = mp3Total,
|
|
||||||
skippedNoSpotifyTrackId = 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
|
|
||||||
"files have title or artist from tags",
|
|
||||||
)
|
|
||||||
|
|
||||||
var paired = 0
|
|
||||||
var failed = 0
|
|
||||||
var skippedNoSpotifyTrackId = 0
|
|
||||||
for (track in tracks.sortedBy { it.position }) {
|
|
||||||
val tid = track.trackId
|
|
||||||
if (tid.isNullOrBlank()) {
|
|
||||||
skippedNoSpotifyTrackId++
|
|
||||||
failed++
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"skip row pos=${track.position}: no Spotify track id " +
|
|
||||||
"(title='${track.trackName}' artist='${track.artistName}')",
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val title = track.trackName.orEmpty()
|
|
||||||
val artist = track.artistName.orEmpty()
|
|
||||||
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
|
|
||||||
if (pick == null) {
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val err =
|
|
||||||
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
|
|
||||||
if (err != null) {
|
|
||||||
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
|
|
||||||
upsertPairing(playlistId, tid, null, err)
|
|
||||||
failed++
|
|
||||||
} else {
|
|
||||||
upsertPairing(playlistId, tid, pick.toString(), null)
|
|
||||||
paired++
|
|
||||||
pool.removeAll { it.uri == pick }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"pairPlaylistFromFolder done paired=$paired failed=$failed " +
|
|
||||||
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
|
|
||||||
)
|
|
||||||
onFinished(
|
|
||||||
FolderPairingResult(
|
|
||||||
paired = paired,
|
|
||||||
failed = failed,
|
|
||||||
jukeboxRowCount = tracks.size,
|
|
||||||
mp3Count = mp3Total,
|
|
||||||
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 withTitle = tracks.count { !it.trackName.isNullOrBlank() }
|
||||||
|
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
|
||||||
|
"withTitle=$withTitle withArtist=$withArtist",
|
||||||
|
)
|
||||||
|
tracks.take(12).forEach { row ->
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
||||||
|
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tracks.size > 12) {
|
||||||
|
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun matchPlaylistTracks(
|
||||||
|
playlistId: String,
|
||||||
|
tracks: List<TrackRow>,
|
||||||
|
pool: MutableList<Mp3FolderCandidate>,
|
||||||
|
mp3Total: Int,
|
||||||
|
): FolderPairingResult {
|
||||||
|
if (pool.isEmpty()) {
|
||||||
|
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
|
||||||
|
return FolderPairingResult(
|
||||||
|
paired = 0,
|
||||||
|
failed = 0,
|
||||||
|
jukeboxRowCount = tracks.size,
|
||||||
|
mp3Count = mp3Total,
|
||||||
|
skippedNoSpotifyTrackId = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
|
||||||
|
"files have title or artist from tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
var paired = 0
|
||||||
|
var failed = 0
|
||||||
|
var skippedNoSpotifyTrackId = 0
|
||||||
|
for (track in tracks.sortedBy { it.position }) {
|
||||||
|
val tid = track.trackId
|
||||||
|
if (tid.isNullOrBlank()) {
|
||||||
|
skippedNoSpotifyTrackId++
|
||||||
|
failed++
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"skip row pos=${track.position}: no Spotify track id " +
|
||||||
|
"(title='${track.trackName}' artist='${track.artistName}')",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val title = track.trackName.orEmpty()
|
||||||
|
val artist = track.artistName.orEmpty()
|
||||||
|
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
|
||||||
|
if (pick == null) {
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val err =
|
||||||
|
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
|
||||||
|
if (err != null) {
|
||||||
|
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
|
||||||
|
upsertPairing(playlistId, tid, null, err)
|
||||||
|
failed++
|
||||||
|
} else {
|
||||||
|
upsertPairing(playlistId, tid, pick.toString(), null)
|
||||||
|
paired++
|
||||||
|
pool.removeAll { it.uri == pick }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"playlist pairing done paired=$paired failed=$failed " +
|
||||||
|
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
|
||||||
|
)
|
||||||
|
return FolderPairingResult(
|
||||||
|
paired = paired,
|
||||||
|
failed = failed,
|
||||||
|
jukeboxRowCount = tracks.size,
|
||||||
|
mp3Count = mp3Total,
|
||||||
|
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */
|
||||||
|
val collectRunData: Flow<Boolean> =
|
||||||
|
dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_COLLECT_RUN_DATA] == 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 +55,22 @@ class UserPreferencesRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setAnnotationMode(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_ANNOTATION_MODE] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCollectRunData(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_COLLECT_RUN_DATA] = 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")
|
||||||
|
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,71 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private var positionPollJob: Job? = null
|
||||||
|
private var positionCachePollJob: 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
|
||||||
|
|
||||||
|
/** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */
|
||||||
|
@Volatile
|
||||||
|
private var cachedPlaybackPositionMs: Long = 0L
|
||||||
|
|
||||||
|
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 +140,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 +196,50 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positionCachePollJob?.cancel()
|
||||||
|
positionCachePollJob =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(POSITION_CACHE_INTERVAL_MS)
|
||||||
|
refreshCachedPlaybackPositionMs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePlayer() {
|
||||||
|
positionPollJob?.cancel()
|
||||||
|
positionPollJob = null
|
||||||
|
positionCachePollJob?.cancel()
|
||||||
|
positionCachePollJob = 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 +253,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()) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
withContext(Dispatchers.Main) {
|
||||||
stopSelf()
|
releasePlayer()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
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 +326,58 @@ 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 refreshCachedPlaybackPositionMs() {
|
||||||
|
val p = player ?: return
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
cachedPlaybackPositionMs = 0L
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
||||||
|
cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgressFromPlayer() {
|
||||||
|
val p = player ?: return
|
||||||
|
refreshCachedPlaybackPositionMs()
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
||||||
|
val positionMs = cachedPlaybackPositionMs
|
||||||
|
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 +385,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 +403,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,33 +430,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
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
|
||||||
|
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
|
||||||
|
*/
|
||||||
|
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
|
||||||
|
|
||||||
private fun refreshForegroundNotification() {
|
private fun refreshForegroundNotification() {
|
||||||
if (queue.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
@@ -332,6 +517,9 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
positionPollJob?.cancel()
|
||||||
|
positionCachePollJob?.cancel()
|
||||||
|
releasePlayer()
|
||||||
mediaSession.run {
|
mediaSession.run {
|
||||||
isActive = false
|
isActive = false
|
||||||
release()
|
release()
|
||||||
@@ -345,6 +533,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 +545,9 @@ class PlaybackService : Service() {
|
|||||||
progress = 0f,
|
progress = 0f,
|
||||||
durationSeconds = 180,
|
durationSeconds = 180,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
|
currentTrackId = null,
|
||||||
|
currentQueueIndex = 0,
|
||||||
|
queueSize = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,9 +557,15 @@ 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 POSITION_CACHE_INTERVAL_MS = 20L
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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.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,7 +72,11 @@ fun LockstepAppNavHost(
|
|||||||
navController.navigate(Routes.pairing(playlist.id))
|
navController.navigate(Routes.pairing(playlist.id))
|
||||||
} else {
|
} else {
|
||||||
startPlaylistPlayback(activity, playlist.id)
|
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 = {
|
onOpenSettings = {
|
||||||
@@ -109,6 +115,22 @@ fun LockstepAppNavHost(
|
|||||||
NowPlayingRoute(
|
NowPlayingRoute(
|
||||||
playlistId = playlistId,
|
playlistId = playlistId,
|
||||||
playback = playback,
|
playback = playback,
|
||||||
|
viewModel = viewModel,
|
||||||
|
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() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package at.lockstep.player.ui
|
package at.lockstep.player.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
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.Row
|
||||||
@@ -22,9 +26,11 @@ import androidx.compose.material3.Slider
|
|||||||
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.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
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
|
||||||
@@ -34,8 +40,13 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import at.lockstep.player.LockstepViewModel
|
||||||
import at.lockstep.player.playback.PlaybackService
|
import at.lockstep.player.playback.PlaybackService
|
||||||
import at.lockstep.player.R
|
import at.lockstep.player.R
|
||||||
|
import at.lockstep.player.util.RunDataCollector
|
||||||
|
import at.lockstep.player.util.RunDataStorage
|
||||||
|
|
||||||
data class NowPlayingUiState(
|
data class NowPlayingUiState(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -153,10 +164,38 @@ fun NowPlayingScreen(
|
|||||||
fun NowPlayingRoute(
|
fun NowPlayingRoute(
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
playback: PlaybackService?,
|
playback: PlaybackService?,
|
||||||
|
viewModel: LockstepViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||||
|
val collector = remember { RunDataCollector(context) }
|
||||||
|
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||||
|
|
||||||
|
val locationPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
onResult = { granted ->
|
||||||
|
if (collectRunData) {
|
||||||
|
collector.start(enableLocation = granted)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun startRunDataCollection() {
|
||||||
|
val hasLocation =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!hasLocation) {
|
||||||
|
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
collector.start(enableLocation = hasLocation)
|
||||||
|
}
|
||||||
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||||
|
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var currentQueueIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
var ui by remember {
|
var ui by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
NowPlayingUiState(
|
NowPlayingUiState(
|
||||||
@@ -170,9 +209,15 @@ fun NowPlayingRoute(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playlistId) {
|
||||||
|
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(playback) {
|
LaunchedEffect(playback) {
|
||||||
val service = playback ?: return@LaunchedEffect
|
val service = playback ?: return@LaunchedEffect
|
||||||
service.uiState.collect { p ->
|
service.uiState.collect { p ->
|
||||||
|
currentTrackId = p.currentTrackId
|
||||||
|
currentQueueIndex = p.currentQueueIndex
|
||||||
ui =
|
ui =
|
||||||
NowPlayingUiState(
|
NowPlayingUiState(
|
||||||
title = p.title,
|
title = p.title,
|
||||||
@@ -185,6 +230,64 @@ fun NowPlayingRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(collectRunData, playback) {
|
||||||
|
if (!collectRunData) {
|
||||||
|
collector.setCollectingEnabled(false)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val service = playback ?: run {
|
||||||
|
collector.setCollectingEnabled(false)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
collector.setPlaybackPositionMsProvider { service.getPlaybackPositionMs() }
|
||||||
|
collector.setCollectingEnabled(true)
|
||||||
|
var lastTrackId: String? = null
|
||||||
|
service.uiState.collect { state ->
|
||||||
|
val trackId = state.currentTrackId
|
||||||
|
if (trackId != null && trackId != lastTrackId) {
|
||||||
|
collector.markSongStart()
|
||||||
|
lastTrackId = trackId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
|
||||||
|
if (!collectRunData) return@LaunchedEffect
|
||||||
|
val service = playback ?: return@LaunchedEffect
|
||||||
|
service.trackBoundaryEvents.collect { event ->
|
||||||
|
val snapshot = collector.snapshotAndClear()
|
||||||
|
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(collectRunData) {
|
||||||
|
if (collectRunData) {
|
||||||
|
startRunDataCollection()
|
||||||
|
} else {
|
||||||
|
collector.stop()
|
||||||
|
collector.snapshotAndClear()
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (collectRunData) {
|
||||||
|
val snapshot = collector.snapshotAndClear()
|
||||||
|
val trackId = currentTrackId
|
||||||
|
if (!snapshot.isEmpty() && trackId != null) {
|
||||||
|
viewModel.persistRunDataForCurrentTrack(
|
||||||
|
playlistId = playlistId,
|
||||||
|
playlistDisplayName = playlistDisplayName,
|
||||||
|
runSessionFolder = runSessionFolder,
|
||||||
|
trackId = trackId,
|
||||||
|
title = ui.title,
|
||||||
|
artist = ui.artist,
|
||||||
|
queueIndex = currentQueueIndex,
|
||||||
|
snapshot = snapshot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collector.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,8 @@ fun SettingsScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||||
|
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -51,6 +59,54 @@ 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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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_collect_run_data),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.settings_collect_run_data_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = collectRunData,
|
||||||
|
onCheckedChange = { viewModel.setCollectRunData(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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/src/main/java/at/lockstep/player/util/RunDataCollector.kt
Normal file
228
app/src/main/java/at/lockstep/player/util/RunDataCollector.kt
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class RunDataCollector(
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val sensorManager = appContext.getSystemService(SensorManager::class.java)
|
||||||
|
private val locationManager = appContext.getSystemService(LocationManager::class.java)
|
||||||
|
private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||||
|
private val gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||||
|
private val handlerThread = HandlerThread("RunDataCollect").apply { start() }
|
||||||
|
private val handler = Handler(handlerThread.looper)
|
||||||
|
|
||||||
|
private val accelBuffer = mutableListOf<RunAccelSample>()
|
||||||
|
private val gyroBuffer = mutableListOf<RunDataSample>()
|
||||||
|
private val gpsBuffer = mutableListOf<RunGpsSample>()
|
||||||
|
|
||||||
|
/** Baseline sensor/GPS time for the current song; set on the first sample after [markSongStart]. */
|
||||||
|
private var songStartElapsedRealtimeNanos: Long? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var collectingEnabled = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var playbackPositionMsProvider: () -> Long = { 0L }
|
||||||
|
|
||||||
|
private var sensorsRegistered = false
|
||||||
|
private var locationRegistered = false
|
||||||
|
|
||||||
|
private val sensorListener =
|
||||||
|
object : SensorEventListener {
|
||||||
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
|
if (!collectingEnabled) return
|
||||||
|
val timestamp = relativeTimestampNanos(event.timestamp) ?: return
|
||||||
|
when (event.sensor.type) {
|
||||||
|
Sensor.TYPE_ACCELEROMETER -> {
|
||||||
|
val sample =
|
||||||
|
RunAccelSample(
|
||||||
|
timestampNanos = timestamp,
|
||||||
|
positionMs = playbackPositionMsProvider(),
|
||||||
|
values = floatArrayOf(event.values[0], event.values[1], event.values[2]),
|
||||||
|
)
|
||||||
|
synchronized(accelBuffer) {
|
||||||
|
accelBuffer.add(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sensor.TYPE_GYROSCOPE -> {
|
||||||
|
val sample =
|
||||||
|
RunDataSample(
|
||||||
|
timestampNanos = timestamp,
|
||||||
|
values = floatArrayOf(event.values[0], event.values[1], event.values[2]),
|
||||||
|
)
|
||||||
|
synchronized(gyroBuffer) {
|
||||||
|
gyroBuffer.add(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(
|
||||||
|
sensor: Sensor?,
|
||||||
|
accuracy: Int,
|
||||||
|
) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private val locationListener =
|
||||||
|
LocationListener { location ->
|
||||||
|
if (!collectingEnabled) return@LocationListener
|
||||||
|
recordGpsLocation(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(enableLocation: Boolean) {
|
||||||
|
startSensors()
|
||||||
|
if (enableLocation) {
|
||||||
|
startLocationUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSensors() {
|
||||||
|
if (sensorsRegistered || sensorManager == null) return
|
||||||
|
accelerometer?.let {
|
||||||
|
sensorManager.registerListener(
|
||||||
|
sensorListener,
|
||||||
|
it,
|
||||||
|
SensorManager.SENSOR_DELAY_GAME,
|
||||||
|
0,
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gyroscope?.let {
|
||||||
|
sensorManager.registerListener(
|
||||||
|
sensorListener,
|
||||||
|
it,
|
||||||
|
SensorManager.SENSOR_DELAY_GAME,
|
||||||
|
0,
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sensorsRegistered = accelerometer != null || gyroscope != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocationUpdates() {
|
||||||
|
if (locationRegistered || locationManager == null) return
|
||||||
|
if (!hasLocationPermission()) return
|
||||||
|
val providers =
|
||||||
|
listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||||
|
.filter { locationManager.isProviderEnabled(it) }
|
||||||
|
if (providers.isEmpty()) return
|
||||||
|
for (provider in providers) {
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
provider,
|
||||||
|
GPS_MIN_TIME_MS,
|
||||||
|
0f,
|
||||||
|
locationListener,
|
||||||
|
handler.looper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
locationRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
stopLocationUpdates()
|
||||||
|
stopSensors()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopSensors() {
|
||||||
|
if (!sensorsRegistered || sensorManager == null) return
|
||||||
|
sensorManager.unregisterListener(sensorListener)
|
||||||
|
sensorsRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLocationUpdates() {
|
||||||
|
if (!locationRegistered || locationManager == null) return
|
||||||
|
locationManager.removeUpdates(locationListener)
|
||||||
|
locationRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
stop()
|
||||||
|
handlerThread.quitSafely()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markSongStart() {
|
||||||
|
songStartElapsedRealtimeNanos = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCollectingEnabled(enabled: Boolean) {
|
||||||
|
collectingEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPlaybackPositionMsProvider(provider: () -> Long) {
|
||||||
|
playbackPositionMsProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshotAndClear(): RunTrackDataSnapshot =
|
||||||
|
RunTrackDataSnapshot(
|
||||||
|
accelerometer =
|
||||||
|
synchronized(accelBuffer) {
|
||||||
|
accelBuffer.toList().also { accelBuffer.clear() }
|
||||||
|
},
|
||||||
|
gyroscope =
|
||||||
|
synchronized(gyroBuffer) {
|
||||||
|
gyroBuffer.toList().also { gyroBuffer.clear() }
|
||||||
|
},
|
||||||
|
gps =
|
||||||
|
synchronized(gpsBuffer) {
|
||||||
|
gpsBuffer.toList().also { gpsBuffer.clear() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun relativeTimestampNanos(elapsedRealtimeNanos: Long): Long? {
|
||||||
|
val start =
|
||||||
|
songStartElapsedRealtimeNanos ?: run {
|
||||||
|
songStartElapsedRealtimeNanos = elapsedRealtimeNanos
|
||||||
|
elapsedRealtimeNanos
|
||||||
|
}
|
||||||
|
return elapsedRealtimeNanos - start
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordGpsLocation(location: Location) {
|
||||||
|
val elapsedNs =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
location.elapsedRealtimeNanos
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
location.time * 1_000_000L
|
||||||
|
}
|
||||||
|
val timestamp = relativeTimestampNanos(elapsedNs) ?: return
|
||||||
|
synchronized(gpsBuffer) {
|
||||||
|
val last = gpsBuffer.lastOrNull()
|
||||||
|
if (last != null && timestamp - last.timestampNanos < GPS_MIN_TIME_NS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gpsBuffer.add(
|
||||||
|
RunGpsSample(
|
||||||
|
timestampNanos = timestamp,
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
altitude = location.altitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasLocationPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val GPS_MIN_TIME_MS = 1_000L
|
||||||
|
private const val GPS_MIN_TIME_NS = 1_000_000_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/at/lockstep/player/util/RunDataSample.kt
Normal file
19
app/src/main/java/at/lockstep/player/util/RunDataSample.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
data class RunDataSample(
|
||||||
|
/** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */
|
||||||
|
val timestampNanos: Long,
|
||||||
|
val values: FloatArray,
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is RunDataSample) return false
|
||||||
|
return timestampNanos == other.timestampNanos && values.contentEquals(other.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = timestampNanos.hashCode()
|
||||||
|
result = 31 * result + values.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/src/main/java/at/lockstep/player/util/RunDataStorage.kt
Normal file
180
app/src/main/java/at/lockstep/player/util/RunDataStorage.kt
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import at.lockstep.player.BuildConfig
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object RunDataStorage {
|
||||||
|
private const val APP_DIR = "Lockstep"
|
||||||
|
|
||||||
|
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
||||||
|
fun newRunSessionFolderName(): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
||||||
|
|
||||||
|
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
||||||
|
fun documentsRelativePath(runSessionFolder: String): String =
|
||||||
|
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
||||||
|
|
||||||
|
fun writeRunDataFile(
|
||||||
|
context: Context,
|
||||||
|
runSessionFolder: String,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
metaContentUri: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
snapshot: RunTrackDataSnapshot,
|
||||||
|
): Uri? {
|
||||||
|
if (snapshot.isEmpty()) return null
|
||||||
|
|
||||||
|
val safeName =
|
||||||
|
playlistDisplayName
|
||||||
|
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||||
|
.trim()
|
||||||
|
.ifBlank { "playlist" }
|
||||||
|
.take(120)
|
||||||
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
|
val fileName = "${safeName}_$suffix.json"
|
||||||
|
|
||||||
|
val jsonString =
|
||||||
|
JSONObject()
|
||||||
|
.apply {
|
||||||
|
put("data", accelToJsonArray(snapshot.accelerometer))
|
||||||
|
put("gyro", samplesToJsonArray(snapshot.gyroscope))
|
||||||
|
put("gps", gpsToJsonArray(snapshot.gps))
|
||||||
|
put("meta", metaContentUri)
|
||||||
|
put("title", title)
|
||||||
|
put("artist", artist)
|
||||||
|
put("versionCode", BuildConfig.VERSION_CODE)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
|
||||||
|
} else {
|
||||||
|
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
||||||
|
val array = JSONArray()
|
||||||
|
for (sample in samples) {
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("timestamp", sample.timestampNanos)
|
||||||
|
put("positionMs", sample.positionMs)
|
||||||
|
put(
|
||||||
|
"values",
|
||||||
|
JSONArray().apply {
|
||||||
|
for (v in sample.values) {
|
||||||
|
put(v.toDouble())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun samplesToJsonArray(samples: List<RunDataSample>): JSONArray {
|
||||||
|
val array = JSONArray()
|
||||||
|
for (sample in samples) {
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("timestamp", sample.timestampNanos)
|
||||||
|
put(
|
||||||
|
"values",
|
||||||
|
JSONArray().apply {
|
||||||
|
for (v in sample.values) {
|
||||||
|
put(v.toDouble())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gpsToJsonArray(samples: List<RunGpsSample>): JSONArray {
|
||||||
|
val array = JSONArray()
|
||||||
|
for (sample in samples) {
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("timestamp", sample.timestampNanos)
|
||||||
|
put(
|
||||||
|
"values",
|
||||||
|
JSONArray().apply {
|
||||||
|
put(sample.latitude)
|
||||||
|
put(sample.longitude)
|
||||||
|
put(sample.altitude)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeViaMediaStore(
|
||||||
|
context: Context,
|
||||||
|
runSessionFolder: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? {
|
||||||
|
val resolver = context.applicationContext.contentResolver
|
||||||
|
val relativePath = documentsRelativePath(runSessionFolder)
|
||||||
|
val pending =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
|
val uri = resolver.insert(collection, pending) ?: return null
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
|
stream.write(jsonString.toByteArray(Charsets.UTF_8))
|
||||||
|
} ?: return null
|
||||||
|
val published =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
}
|
||||||
|
resolver.update(uri, published, null, null)
|
||||||
|
return uri
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun writeViaPublicDocumentsDir(
|
||||||
|
runSessionFolder: String,
|
||||||
|
fileName: String,
|
||||||
|
jsonString: String,
|
||||||
|
): Uri? {
|
||||||
|
val dir =
|
||||||
|
File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
|
||||||
|
"$APP_DIR/$runSessionFolder",
|
||||||
|
)
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
file.writeText(jsonString)
|
||||||
|
return Uri.fromFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
data class RunGpsSample(
|
||||||
|
val timestampNanos: Long,
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val altitude: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RunTrackDataSnapshot(
|
||||||
|
val accelerometer: List<RunAccelSample>,
|
||||||
|
val gyroscope: List<RunDataSample>,
|
||||||
|
val gps: List<RunGpsSample>,
|
||||||
|
) {
|
||||||
|
fun isEmpty(): Boolean =
|
||||||
|
accelerometer.isEmpty() && gyroscope.isEmpty() && gps.isEmpty()
|
||||||
|
}
|
||||||
41
app/src/main/java/at/lockstep/player/util/SafInitialUris.kt
Normal file
41
app/src/main/java/at/lockstep/player/util/SafInitialUris.kt
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,21 @@
|
|||||||
<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="settings_collect_run_data">Collect run data</string>
|
||||||
|
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Lockstep/ under a timestamped run folder.</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user