Compare commits
7 Commits
4315944733
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9654aa7e | |||
| d663a1c9de | |||
| 183efd343e | |||
| eb53d1e0ad | |||
| b5bc01fac2 | |||
| 698605d7a9 | |||
| c11ad041d7 |
@@ -6,6 +6,11 @@
|
||||
<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.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
@@ -13,9 +18,9 @@
|
||||
<application
|
||||
android:name=".LockstepApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher_layer"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher_layer"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LockstepPlayer">
|
||||
<activity
|
||||
|
||||
@@ -16,6 +16,8 @@ import at.lockstep.player.util.FolderMp3Scanner
|
||||
import at.lockstep.player.util.MediaStoreMp3Scanner
|
||||
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
||||
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.mp3DisplayNameFromUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -66,12 +68,25 @@ class LockstepViewModel(
|
||||
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>()
|
||||
|
||||
/**
|
||||
@@ -175,6 +190,66 @@ class LockstepViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
val token = spotifyAccessToken.value
|
||||
if (token.isNullOrBlank()) {
|
||||
|
||||
@@ -33,6 +33,12 @@ class UserPreferencesRepository(
|
||||
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) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ONBOARDING_COMPLETE] = done
|
||||
@@ -55,9 +61,16 @@ class UserPreferencesRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCollectRunData(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_COLLECT_RUN_DATA] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
|
||||
private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token")
|
||||
private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
|
||||
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
|
||||
}
|
||||
}
|
||||
|
||||
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal file
75
app/src/main/java/at/lockstep/player/pasada/LibPasada.java
Normal file
@@ -0,0 +1,75 @@
|
||||
package at.lockstep.player.pasada;
|
||||
|
||||
/**
|
||||
* JNI entry point for libpasada. Used by {@link at.lockstep.player.playback.engine.PasadaMusicPlayerEngine};
|
||||
* {@link at.lockstep.player.playback.PlaybackService} still defaults to
|
||||
* {@link at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine} until the native library is ready.
|
||||
*
|
||||
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
|
||||
*
|
||||
* <p>Call {@link #loadNative()} once before any other method once the {@code pasada} shared
|
||||
* library is added via CMake (step 3).
|
||||
*/
|
||||
public final class LibPasada {
|
||||
|
||||
private static boolean loaded;
|
||||
|
||||
private LibPasada() {}
|
||||
|
||||
/** Loads {@code libpasada.so}. Safe to call multiple times. */
|
||||
public static synchronized void loadNative() {
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
System.loadLibrary("pasada");
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */
|
||||
public static native void init();
|
||||
|
||||
/** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */
|
||||
public static native void feedAccel(float x, float y, float z, long timestampNanos);
|
||||
|
||||
/**
|
||||
* Open MP3 from an already-open file descriptor and begin adaptive playback.
|
||||
*
|
||||
* @param fd open read FD (Java retains ownership; do not close until track changes)
|
||||
* @param offset start offset within the FD (0 for whole file)
|
||||
* @param length byte length from offset ({@code -1} if unknown / to EOF)
|
||||
*/
|
||||
public static native void play(int fd, long offset, long length);
|
||||
|
||||
/** PLAYING → PAUSED (silent output, graph kept alive). */
|
||||
public static native void pause();
|
||||
|
||||
/** PAUSED → PLAYING (same track, same decode position, same FD). */
|
||||
public static native void resume();
|
||||
|
||||
/** Tear down Oboe for this run segment → STOPPED. */
|
||||
public static native void stop();
|
||||
|
||||
/**
|
||||
* Milliseconds from the start of the current track — same timebase as ExoPlayer
|
||||
* {@code getCurrentPosition()}. Safe to poll from background threads.
|
||||
*/
|
||||
public static native long getCurrentPositionMs();
|
||||
|
||||
/** Track duration in ms, or {@code 0} if not yet known. */
|
||||
public static native long getDurationMs();
|
||||
|
||||
/** Whether adapted audio is actively being output (not paused, not finished). */
|
||||
public static native boolean isPlaying();
|
||||
|
||||
/** Current native state; see {@link PasadaState}. */
|
||||
public static native int getState();
|
||||
|
||||
/** Seek within the current track. */
|
||||
public static native void seekTo(long positionMs);
|
||||
|
||||
/** Runtime metrics / last error string for logging and debug UI. */
|
||||
public static native String getDiagnostics();
|
||||
|
||||
/** Register listener for async events raised from the audio/native thread. */
|
||||
public static native void setPlaybackListener(PasadaPlaybackListener listener);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package at.lockstep.player.pasada;
|
||||
|
||||
/**
|
||||
* Callbacks invoked from native (Oboe or internal worker thread).
|
||||
* Implementations must post to the main thread if they touch UI or service state.
|
||||
*/
|
||||
public interface PasadaPlaybackListener {
|
||||
|
||||
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
|
||||
void onTrackFinished();
|
||||
|
||||
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
|
||||
void onError(int errorCode, String message);
|
||||
}
|
||||
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal file
26
app/src/main/java/at/lockstep/player/pasada/PasadaState.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package at.lockstep.player.pasada;
|
||||
|
||||
/** Mirrors the libpasada state machine documented in DESIGN.md. */
|
||||
public enum PasadaState {
|
||||
LOADED(0),
|
||||
INITIALIZED(1),
|
||||
PLAYING(2),
|
||||
PAUSED(3),
|
||||
FINISHED(4),
|
||||
STOPPED(5);
|
||||
|
||||
public final int code;
|
||||
|
||||
PasadaState(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public static PasadaState fromCode(int code) {
|
||||
for (PasadaState state : values()) {
|
||||
if (state.code == code) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown PasadaState code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,11 @@ import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import at.lockstep.player.LockstepApplication
|
||||
import at.lockstep.player.MainActivity
|
||||
import at.lockstep.player.R
|
||||
import at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
|
||||
import at.lockstep.player.playback.engine.MusicPlayerEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -36,10 +35,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use
|
||||
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is
|
||||
* the same timeline used for seeking and end-of-track reporting — see Media3
|
||||
* [Player] / [ExoPlayer] documentation.
|
||||
* Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
|
||||
* beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
|
||||
* current media item).
|
||||
*/
|
||||
class PlaybackService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
@@ -48,8 +46,9 @@ class PlaybackService : Service() {
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
private var engine: MusicPlayerEngine? = null
|
||||
private var positionPollJob: Job? = null
|
||||
private var positionCachePollJob: Job? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(PlaybackUiState.initial())
|
||||
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
|
||||
@@ -64,28 +63,37 @@ class PlaybackService : Service() {
|
||||
private var queue: List<TrackQueueItem> = emptyList()
|
||||
private var index: Int = 0
|
||||
|
||||
private val playerListener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
if (queue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (index < queue.lastIndex) {
|
||||
emitTrackBoundaryForQueueIndex(
|
||||
index,
|
||||
TrackBoundaryReason.ADVANCED_TO_OTHER_TRACK,
|
||||
)
|
||||
index++
|
||||
publishCurrentTrack()
|
||||
} else {
|
||||
emitTrackBoundaryForQueueIndex(
|
||||
index,
|
||||
TrackBoundaryReason.LAST_TRACK_FINISHED,
|
||||
)
|
||||
setPlaying(false)
|
||||
}
|
||||
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
|
||||
@Volatile
|
||||
private var cachedPlaybackPositionMs: Long = 0L
|
||||
|
||||
private val engineListener =
|
||||
object : MusicPlayerEngine.Listener {
|
||||
override fun onPlaybackEnded() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
errorCode: Int,
|
||||
message: String,
|
||||
) {
|
||||
setPlaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +145,8 @@ class PlaybackService : Service() {
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
val p = player ?: return
|
||||
p.seekTo(pos)
|
||||
updateProgressFromPlayer()
|
||||
engine?.seekTo(pos)
|
||||
updateProgressFromEngine()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -191,15 +198,15 @@ class PlaybackService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePlayer(): ExoPlayer {
|
||||
player?.let {
|
||||
private fun ensureEngine(): MusicPlayerEngine {
|
||||
engine?.let {
|
||||
return it
|
||||
}
|
||||
return ExoPlayer.Builder(this)
|
||||
.build()
|
||||
return ExoPlayerMusicPlayerEngine(this)
|
||||
.also {
|
||||
it.addListener(playerListener)
|
||||
player = it
|
||||
it.setListener(engineListener)
|
||||
it.initSession()
|
||||
engine = it
|
||||
startPositionPolling()
|
||||
}
|
||||
}
|
||||
@@ -210,19 +217,29 @@ class PlaybackService : Service() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(UPDATE_INTERVAL_MS)
|
||||
if (player != null && queue.isNotEmpty()) {
|
||||
updateProgressFromPlayer()
|
||||
if (engine != null && queue.isNotEmpty()) {
|
||||
updateProgressFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
positionCachePollJob?.cancel()
|
||||
positionCachePollJob =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(POSITION_CACHE_INTERVAL_MS)
|
||||
refreshCachedPlaybackPositionMs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
private fun releaseEngine() {
|
||||
positionPollJob?.cancel()
|
||||
positionPollJob = null
|
||||
player?.removeListener(playerListener)
|
||||
player?.release()
|
||||
player = null
|
||||
positionCachePollJob?.cancel()
|
||||
positionCachePollJob = null
|
||||
engine?.setListener(null)
|
||||
engine?.releaseSession()
|
||||
engine = null
|
||||
}
|
||||
|
||||
private fun startPlaylist(pid: String) {
|
||||
@@ -252,14 +269,14 @@ class PlaybackService : Service() {
|
||||
index = 0
|
||||
if (queue.isEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
releasePlayer()
|
||||
releaseEngine()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
ensurePlayer()
|
||||
ensureEngine()
|
||||
setPlaying(true)
|
||||
publishCurrentTrack()
|
||||
refreshForegroundNotification()
|
||||
@@ -271,7 +288,7 @@ class PlaybackService : Service() {
|
||||
val item = queue.getOrNull(index) ?: return
|
||||
applyCurrentMediaItem(item)
|
||||
val durationSec =
|
||||
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt())
|
||||
(engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
|
||||
?: (item.durationMsHint / 1000).coerceAtLeast(1)
|
||||
_uiState.value =
|
||||
PlaybackUiState(
|
||||
@@ -284,18 +301,21 @@ class PlaybackService : Service() {
|
||||
currentQueueIndex = index,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updateProgressFromPlayer()
|
||||
updateProgressFromEngine()
|
||||
updateSessionMetadata(item, durationSec)
|
||||
updatePlaybackStateFromPlayer()
|
||||
updatePlaybackStateFromEngine()
|
||||
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
|
||||
val e = ensureEngine()
|
||||
e.prepareTrack(uri)
|
||||
if (_uiState.value.isPlaying) {
|
||||
e.play()
|
||||
} else {
|
||||
e.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSessionMetadata(
|
||||
@@ -312,22 +332,32 @@ class PlaybackService : Service() {
|
||||
}
|
||||
|
||||
private fun currentDurationMs(): Long {
|
||||
val p = player
|
||||
val fromPlayer = p?.duration?.takeIf { it > 0 }
|
||||
if (fromPlayer != null) {
|
||||
return fromPlayer
|
||||
val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
|
||||
if (fromEngine != null) {
|
||||
return fromEngine
|
||||
}
|
||||
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
|
||||
return hint
|
||||
}
|
||||
|
||||
private fun updateProgressFromPlayer() {
|
||||
val p = player ?: return
|
||||
private fun refreshCachedPlaybackPositionMs() {
|
||||
val e = engine ?: return
|
||||
if (queue.isEmpty()) {
|
||||
cachedPlaybackPositionMs = 0L
|
||||
return
|
||||
}
|
||||
val durationMs = currentDurationMs().coerceAtLeast(1L)
|
||||
val positionMs = p.currentPosition.coerceIn(0L, durationMs)
|
||||
cachedPlaybackPositionMs = e.getCurrentPositionMs().coerceIn(0L, durationMs)
|
||||
}
|
||||
|
||||
private fun updateProgressFromEngine() {
|
||||
val e = engine ?: 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 =
|
||||
@@ -338,12 +368,12 @@ class PlaybackService : Service() {
|
||||
currentQueueIndex = index,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updatePlaybackStateFromPlayer()
|
||||
updatePlaybackStateFromEngine()
|
||||
}
|
||||
|
||||
private fun updatePlaybackStateFromPlayer() {
|
||||
val p = player
|
||||
val positionMs = p?.currentPosition ?: 0L
|
||||
private fun updatePlaybackStateFromEngine() {
|
||||
val e = engine
|
||||
val positionMs = e?.getCurrentPositionMs() ?: 0L
|
||||
val actions =
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
@@ -351,7 +381,7 @@ class PlaybackService : Service() {
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
val state =
|
||||
if (_uiState.value.isPlaying && p?.isPlaying == true) {
|
||||
if (_uiState.value.isPlaying && e?.isPlaying() == true) {
|
||||
PlaybackStateCompat.STATE_PLAYING
|
||||
} else {
|
||||
PlaybackStateCompat.STATE_PAUSED
|
||||
@@ -366,8 +396,12 @@ class PlaybackService : Service() {
|
||||
|
||||
private fun setPlaying(playing: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isPlaying = playing)
|
||||
player?.playWhenReady = playing
|
||||
updatePlaybackStateFromPlayer()
|
||||
if (playing) {
|
||||
engine?.play()
|
||||
} else {
|
||||
engine?.pause()
|
||||
}
|
||||
updatePlaybackStateFromEngine()
|
||||
refreshForegroundNotification()
|
||||
}
|
||||
|
||||
@@ -404,20 +438,18 @@ class PlaybackService : Service() {
|
||||
|
||||
fun requestSeek(fraction: Float) {
|
||||
if (queue.isEmpty()) return
|
||||
val p = player ?: return
|
||||
val e = engine ?: return
|
||||
val durationMs = currentDurationMs()
|
||||
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
|
||||
p.seekTo(positionMs)
|
||||
updateProgressFromPlayer()
|
||||
e.seekTo(positionMs)
|
||||
updateProgressFromEngine()
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition].
|
||||
* May be called from any thread per Media3 [Player] contract.
|
||||
* Milliseconds from the start of the current track — same timebase as the playback engine.
|
||||
* Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
|
||||
*/
|
||||
fun getPlaybackPositionMs(): Long {
|
||||
return player?.currentPosition ?: 0L
|
||||
}
|
||||
fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
|
||||
|
||||
private fun refreshForegroundNotification() {
|
||||
if (queue.isEmpty()) return
|
||||
@@ -494,7 +526,8 @@ class PlaybackService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
positionPollJob?.cancel()
|
||||
releasePlayer()
|
||||
positionCachePollJob?.cancel()
|
||||
releaseEngine()
|
||||
mediaSession.run {
|
||||
isActive = false
|
||||
release()
|
||||
@@ -532,12 +565,13 @@ class PlaybackService : Service() {
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val localUri: Uri?,
|
||||
/** Fallback when [ExoPlayer] has not reported duration yet (from jukebox or default). */
|
||||
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||
val durationMsHint: Int,
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package at.lockstep.player.playback.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
||||
/** [MusicPlayerEngine] backed by Media3 [ExoPlayer] until libpasada is wired in. */
|
||||
class ExoPlayerMusicPlayerEngine(
|
||||
context: Context,
|
||||
) : MusicPlayerEngine {
|
||||
private val appContext = context.applicationContext
|
||||
private var player: ExoPlayer? = null
|
||||
private var listener: MusicPlayerEngine.Listener? = null
|
||||
|
||||
private val playerListener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
listener?.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun initSession() {
|
||||
if (player != null) {
|
||||
return
|
||||
}
|
||||
player =
|
||||
ExoPlayer.Builder(appContext)
|
||||
.build()
|
||||
.also { it.addListener(playerListener) }
|
||||
}
|
||||
|
||||
override fun prepareTrack(uri: Uri) {
|
||||
val p = requirePlayer()
|
||||
p.setMediaItem(MediaItem.fromUri(uri))
|
||||
p.prepare()
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
requirePlayer().playWhenReady = true
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
player?.playWhenReady = false
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
requirePlayer().seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun getCurrentPositionMs(): Long = player?.currentPosition ?: 0L
|
||||
|
||||
override fun getDurationMs(): Long = player?.duration?.takeIf { it > 0 } ?: 0L
|
||||
|
||||
override fun isPlaying(): Boolean = player?.isPlaying == true
|
||||
|
||||
override fun releaseSession() {
|
||||
player?.removeListener(playerListener)
|
||||
player?.release()
|
||||
player = null
|
||||
}
|
||||
|
||||
override fun setListener(listener: MusicPlayerEngine.Listener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
private fun requirePlayer(): ExoPlayer =
|
||||
player ?: throw IllegalStateException("Call initSession() before using the player")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package at.lockstep.player.playback.engine
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Playback engine used by [at.lockstep.player.playback.PlaybackService].
|
||||
* ExoPlayer today; libpasada via JNI later.
|
||||
*/
|
||||
interface MusicPlayerEngine {
|
||||
/** Arm the engine for a new playlist/run session. */
|
||||
fun initSession()
|
||||
|
||||
/**
|
||||
* Load a track from a local URI. Asynchronous — playback starts after prepare when [play] is
|
||||
* called (or immediately if [playWhenReady] is set via [play]).
|
||||
*/
|
||||
fun prepareTrack(uri: Uri)
|
||||
|
||||
fun play()
|
||||
|
||||
fun pause()
|
||||
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
fun getCurrentPositionMs(): Long
|
||||
|
||||
fun getDurationMs(): Long
|
||||
|
||||
fun isPlaying(): Boolean
|
||||
|
||||
/** Release resources for this session. */
|
||||
fun releaseSession()
|
||||
|
||||
fun setListener(listener: Listener?)
|
||||
|
||||
interface Listener {
|
||||
fun onPlaybackEnded()
|
||||
|
||||
fun onError(
|
||||
errorCode: Int,
|
||||
message: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package at.lockstep.player.playback.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelFileDescriptor
|
||||
import at.lockstep.player.pasada.LibPasada
|
||||
import at.lockstep.player.pasada.PasadaPlaybackListener
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* [MusicPlayerEngine] backed by libpasada JNI. Opens local URIs into file descriptors for native
|
||||
* decode; maps prepare/play/pause semantics onto [LibPasada.play] / [LibPasada.resume].
|
||||
*/
|
||||
class PasadaMusicPlayerEngine(
|
||||
context: Context,
|
||||
) : MusicPlayerEngine {
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var listener: MusicPlayerEngine.Listener? = null
|
||||
private var openAsset: AssetFileDescriptor? = null
|
||||
private var openParcel: ParcelFileDescriptor? = null
|
||||
private var trackFd: Int? = null
|
||||
private var trackOffset: Long = 0L
|
||||
private var trackLength: Long = -1L
|
||||
|
||||
/** FD is open but [LibPasada.play] has not been called yet for this track. */
|
||||
private var pendingStart = false
|
||||
|
||||
private var sessionInitialized = false
|
||||
|
||||
private val nativeListener =
|
||||
object : PasadaPlaybackListener {
|
||||
override fun onTrackFinished() {
|
||||
pendingStart = false
|
||||
mainHandler.post {
|
||||
listener?.onPlaybackEnded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
errorCode: Int,
|
||||
message: String,
|
||||
) {
|
||||
mainHandler.post {
|
||||
listener?.onError(errorCode, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun initSession() {
|
||||
if (sessionInitialized) {
|
||||
return
|
||||
}
|
||||
LibPasada.loadNative()
|
||||
LibPasada.setPlaybackListener(nativeListener)
|
||||
LibPasada.init()
|
||||
sessionInitialized = true
|
||||
}
|
||||
|
||||
override fun prepareTrack(uri: Uri) {
|
||||
closeOpenTrack()
|
||||
try {
|
||||
openTrack(uri)
|
||||
pendingStart = true
|
||||
} catch (e: IOException) {
|
||||
closeOpenTrack()
|
||||
listener?.onError(ERROR_OPEN_TRACK, e.message ?: "Failed to open $uri")
|
||||
}
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (pendingStart) {
|
||||
startPendingTrack()
|
||||
return
|
||||
}
|
||||
if (!sessionInitialized) {
|
||||
return
|
||||
}
|
||||
if (!LibPasada.isPlaying()) {
|
||||
LibPasada.resume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (pendingStart) {
|
||||
return
|
||||
}
|
||||
LibPasada.pause()
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (pendingStart) {
|
||||
return
|
||||
}
|
||||
LibPasada.seekTo(positionMs)
|
||||
}
|
||||
|
||||
override fun getCurrentPositionMs(): Long =
|
||||
if (pendingStart || !sessionInitialized) {
|
||||
0L
|
||||
} else {
|
||||
LibPasada.getCurrentPositionMs()
|
||||
}
|
||||
|
||||
override fun getDurationMs(): Long =
|
||||
if (pendingStart || !sessionInitialized) {
|
||||
0L
|
||||
} else {
|
||||
LibPasada.getDurationMs()
|
||||
}
|
||||
|
||||
override fun isPlaying(): Boolean =
|
||||
sessionInitialized && !pendingStart && LibPasada.isPlaying()
|
||||
|
||||
override fun releaseSession() {
|
||||
closeOpenTrack()
|
||||
if (sessionInitialized) {
|
||||
LibPasada.setPlaybackListener(null)
|
||||
LibPasada.stop()
|
||||
sessionInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setListener(listener: MusicPlayerEngine.Listener?) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
private fun startPendingTrack() {
|
||||
val fd = trackFd ?: return
|
||||
LibPasada.play(fd, trackOffset, trackLength)
|
||||
pendingStart = false
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun openTrack(uri: Uri) {
|
||||
val resolver = appContext.contentResolver
|
||||
resolver.openAssetFileDescriptor(uri, "r")?.let { afd ->
|
||||
val pfd =
|
||||
afd.parcelFileDescriptor
|
||||
?: throw IOException("No ParcelFileDescriptor for $uri")
|
||||
openAsset = afd
|
||||
trackFd = pfd.fd
|
||||
trackOffset = afd.startOffset
|
||||
trackLength = if (afd.length >= 0) afd.length else -1L
|
||||
return
|
||||
}
|
||||
val pfd =
|
||||
resolver.openFileDescriptor(uri, "r")
|
||||
?: throw IOException("Cannot open $uri")
|
||||
openParcel = pfd
|
||||
trackFd = pfd.fd
|
||||
trackOffset = 0L
|
||||
trackLength = -1L
|
||||
}
|
||||
|
||||
private fun closeOpenTrack() {
|
||||
openAsset?.close()
|
||||
openAsset = null
|
||||
openParcel?.close()
|
||||
openParcel = null
|
||||
trackFd = null
|
||||
trackOffset = 0L
|
||||
trackLength = -1L
|
||||
pendingStart = false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ERROR_OPEN_TRACK = 1
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ fun LockstepAppNavHost(
|
||||
NowPlayingRoute(
|
||||
playlistId = playlistId,
|
||||
playback = playback,
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -22,9 +26,11 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.tooling.preview.Preview
|
||||
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.R
|
||||
import at.lockstep.player.util.RunDataCollector
|
||||
import at.lockstep.player.util.RunDataStorage
|
||||
|
||||
data class NowPlayingUiState(
|
||||
val title: String,
|
||||
@@ -153,10 +164,38 @@ fun NowPlayingScreen(
|
||||
fun NowPlayingRoute(
|
||||
playlistId: String,
|
||||
playback: PlaybackService?,
|
||||
viewModel: LockstepViewModel,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
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 {
|
||||
mutableStateOf(
|
||||
NowPlayingUiState(
|
||||
@@ -170,9 +209,15 @@ fun NowPlayingRoute(
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
currentTrackId = p.currentTrackId
|
||||
currentQueueIndex = p.currentQueueIndex
|
||||
ui =
|
||||
NowPlayingUiState(
|
||||
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(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
|
||||
@@ -37,6 +37,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
|
||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -82,6 +83,30 @@ fun SettingsScreen(
|
||||
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 = context.getString(R.string.settings_stub_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
24
app/src/main/java/at/lockstep/player/util/RunAccelSample.kt
Normal file
24
app/src/main/java/at/lockstep/player/util/RunAccelSample.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package at.lockstep.player.util
|
||||
|
||||
data class RunAccelSample(
|
||||
/** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */
|
||||
val timestampNanos: Long,
|
||||
/** ExoPlayer position in ms when this sample was taken — frozen while paused. */
|
||||
val positionMs: Long,
|
||||
val values: FloatArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RunAccelSample) return false
|
||||
return timestampNanos == other.timestampNanos &&
|
||||
positionMs == other.positionMs &&
|
||||
values.contentEquals(other.values)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = timestampNanos.hashCode()
|
||||
result = 31 * result + positionMs.hashCode()
|
||||
result = 31 * result + values.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/ic_launcher_bg" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Running shoe, centered in adaptive-icon safe zone -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,70c0,-4 3,-7 7,-7h4l2,8c8,2 18,3 28,2l32,-6c5,-1 9,2 10,7l1,6c0,4 -3,8 -8,9l-38,7c-16,2 -32,0 -46,-6l-2,-1c-4,-2 -7,-6 -6,-11l4,-8z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M32,52c6,-14 22,-22 38,-20l26,4c8,1 14,8 15,16l1,10c0,3 -2,6 -5,7l-8,2 -30,4c-12,1 -24,-4 -32,-13l-6,-9c-2,-3 -1,-7 1,-10z" />
|
||||
<path
|
||||
android:fillAlpha="0.92"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M48,46l18,-2c4,0 7,3 8,7v2l-20,3c-3,0 -6,-2 -7,-5v-3c0,-1 0,-2 1,-2z" />
|
||||
</vector>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_launcher_background" />
|
||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</layer-list>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_bg">#E65100</color>
|
||||
<color name="ic_launcher_bg">#ffffff</color>
|
||||
</resources>
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user