7 Commits

28 changed files with 1211 additions and 111 deletions

View File

@@ -6,6 +6,11 @@
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> 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" />
@@ -13,9 +18,9 @@
<application <application
android:name=".LockstepApplication" android:name=".LockstepApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher_layer" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_layer" android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LockstepPlayer"> android:theme="@style/Theme.LockstepPlayer">
<activity <activity

View File

@@ -16,6 +16,8 @@ import at.lockstep.player.util.FolderMp3Scanner
import at.lockstep.player.util.MediaStoreMp3Scanner 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
@@ -66,12 +68,25 @@ class LockstepViewModel(
false, false,
) )
val collectRunData: StateFlow<Boolean> =
prefs.collectRunData.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
fun setAnnotationMode(enabled: Boolean) { fun setAnnotationMode(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
prefs.setAnnotationMode(enabled) prefs.setAnnotationMode(enabled)
} }
} }
fun setCollectRunData(enabled: Boolean) {
viewModelScope.launch {
prefs.setCollectRunData(enabled)
}
}
private val context get() = getApplication<Application>() 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? { suspend fun syncJukeboxIfToken(): String? {
val token = spotifyAccessToken.value val token = spotifyAccessToken.value
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {

View File

@@ -33,6 +33,12 @@ class UserPreferencesRepository(
prefs[KEY_ANNOTATION_MODE] == true 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
@@ -55,9 +61,16 @@ class UserPreferencesRepository(
} }
} }
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_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode")
private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data")
} }
} }

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

View File

@@ -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);
}

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

View File

@@ -13,12 +13,11 @@ 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 at.lockstep.player.playback.engine.ExoPlayerMusicPlayerEngine
import at.lockstep.player.playback.engine.MusicPlayerEngine
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -36,10 +35,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* Local MP3 playback via Media3 [ExoPlayer]. UI progress and beat taps use * Local MP3 playback via [MusicPlayerEngine] (ExoPlayer today, libpasada later). UI progress and
* [ExoPlayer.getCurrentPosition] (milliseconds from the start of the current media item), which is * beat taps use [MusicPlayerEngine.getCurrentPositionMs] (milliseconds from the start of the
* the same timeline used for seeking and end-of-track reporting — see Media3 * current media item).
* [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)
@@ -48,8 +46,9 @@ class PlaybackService : Service() {
private val binder = LocalBinder() private val binder = LocalBinder()
private var player: ExoPlayer? = null private var engine: MusicPlayerEngine? = null
private var positionPollJob: Job? = 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()
@@ -64,10 +63,13 @@ class PlaybackService : Service() {
private var queue: List<TrackQueueItem> = emptyList() private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0 private var index: Int = 0
private val playerListener = /** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
object : Player.Listener { @Volatile
override fun onPlaybackStateChanged(playbackState: Int) { private var cachedPlaybackPositionMs: Long = 0L
if (playbackState == Player.STATE_ENDED) {
private val engineListener =
object : MusicPlayerEngine.Listener {
override fun onPlaybackEnded() {
if (queue.isEmpty()) { if (queue.isEmpty()) {
return return
} }
@@ -86,6 +88,12 @@ class PlaybackService : Service() {
setPlaying(false) setPlaying(false)
} }
} }
override fun onError(
errorCode: Int,
message: String,
) {
setPlaying(false)
} }
} }
@@ -137,9 +145,8 @@ class PlaybackService : Service() {
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
val p = player ?: return engine?.seekTo(pos)
p.seekTo(pos) updateProgressFromEngine()
updateProgressFromPlayer()
} }
}, },
) )
@@ -191,15 +198,15 @@ class PlaybackService : Service() {
} }
} }
private fun ensurePlayer(): ExoPlayer { private fun ensureEngine(): MusicPlayerEngine {
player?.let { engine?.let {
return it return it
} }
return ExoPlayer.Builder(this) return ExoPlayerMusicPlayerEngine(this)
.build()
.also { .also {
it.addListener(playerListener) it.setListener(engineListener)
player = it it.initSession()
engine = it
startPositionPolling() startPositionPolling()
} }
} }
@@ -210,19 +217,29 @@ class PlaybackService : Service() {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
delay(UPDATE_INTERVAL_MS) delay(UPDATE_INTERVAL_MS)
if (player != null && queue.isNotEmpty()) { if (engine != null && queue.isNotEmpty()) {
updateProgressFromPlayer() updateProgressFromEngine()
} }
} }
} }
positionCachePollJob?.cancel()
positionCachePollJob =
scope.launch {
while (isActive) {
delay(POSITION_CACHE_INTERVAL_MS)
refreshCachedPlaybackPositionMs()
}
}
} }
private fun releasePlayer() { private fun releaseEngine() {
positionPollJob?.cancel() positionPollJob?.cancel()
positionPollJob = null positionPollJob = null
player?.removeListener(playerListener) positionCachePollJob?.cancel()
player?.release() positionCachePollJob = null
player = null engine?.setListener(null)
engine?.releaseSession()
engine = null
} }
private fun startPlaylist(pid: String) { private fun startPlaylist(pid: String) {
@@ -252,14 +269,14 @@ class PlaybackService : Service() {
index = 0 index = 0
if (queue.isEmpty()) { if (queue.isEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
releasePlayer() releaseEngine()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
return@launch return@launch
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
ensurePlayer() ensureEngine()
setPlaying(true) setPlaying(true)
publishCurrentTrack() publishCurrentTrack()
refreshForegroundNotification() refreshForegroundNotification()
@@ -271,7 +288,7 @@ class PlaybackService : Service() {
val item = queue.getOrNull(index) ?: return val item = queue.getOrNull(index) ?: return
applyCurrentMediaItem(item) applyCurrentMediaItem(item)
val durationSec = val durationSec =
(player?.duration?.takeIf { it > 0 }?.div(1000)?.toInt()) (engine?.getDurationMs()?.takeIf { it > 0 }?.div(1000)?.toInt())
?: (item.durationMsHint / 1000).coerceAtLeast(1) ?: (item.durationMsHint / 1000).coerceAtLeast(1)
_uiState.value = _uiState.value =
PlaybackUiState( PlaybackUiState(
@@ -284,18 +301,21 @@ class PlaybackService : Service() {
currentQueueIndex = index, currentQueueIndex = index,
queueSize = queue.size, queueSize = queue.size,
) )
updateProgressFromPlayer() updateProgressFromEngine()
updateSessionMetadata(item, durationSec) updateSessionMetadata(item, durationSec)
updatePlaybackStateFromPlayer() updatePlaybackStateFromEngine()
refreshForegroundNotification() refreshForegroundNotification()
} }
private fun applyCurrentMediaItem(item: TrackQueueItem) { private fun applyCurrentMediaItem(item: TrackQueueItem) {
val uri = item.localUri ?: return val uri = item.localUri ?: return
val p = ensurePlayer() val e = ensureEngine()
p.setMediaItem(MediaItem.fromUri(uri)) e.prepareTrack(uri)
p.prepare() if (_uiState.value.isPlaying) {
p.playWhenReady = _uiState.value.isPlaying e.play()
} else {
e.pause()
}
} }
private fun updateSessionMetadata( private fun updateSessionMetadata(
@@ -312,22 +332,32 @@ class PlaybackService : Service() {
} }
private fun currentDurationMs(): Long { private fun currentDurationMs(): Long {
val p = player val fromEngine = engine?.getDurationMs()?.takeIf { it > 0 }
val fromPlayer = p?.duration?.takeIf { it > 0 } if (fromEngine != null) {
if (fromPlayer != null) { return fromEngine
return fromPlayer
} }
val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong() val hint = queue.getOrNull(index)?.durationMsHint?.toLong() ?: DEFAULT_DURATION_HINT_MS.toLong()
return hint return hint
} }
private fun updateProgressFromPlayer() { private fun refreshCachedPlaybackPositionMs() {
val p = player ?: return val e = engine ?: return
if (queue.isEmpty()) {
cachedPlaybackPositionMs = 0L
return
}
val durationMs = currentDurationMs().coerceAtLeast(1L) 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()) { if (queue.isEmpty()) {
return return
} }
val durationMs = currentDurationMs().coerceAtLeast(1L)
val positionMs = cachedPlaybackPositionMs
val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1) val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1)
_uiState.value = _uiState.value =
@@ -338,12 +368,12 @@ class PlaybackService : Service() {
currentQueueIndex = index, currentQueueIndex = index,
queueSize = queue.size, queueSize = queue.size,
) )
updatePlaybackStateFromPlayer() updatePlaybackStateFromEngine()
} }
private fun updatePlaybackStateFromPlayer() { private fun updatePlaybackStateFromEngine() {
val p = player val e = engine
val positionMs = p?.currentPosition ?: 0L val positionMs = e?.getCurrentPositionMs() ?: 0L
val actions = val actions =
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
@@ -351,7 +381,7 @@ class PlaybackService : Service() {
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO PlaybackStateCompat.ACTION_SEEK_TO
val state = val state =
if (_uiState.value.isPlaying && p?.isPlaying == true) { if (_uiState.value.isPlaying && e?.isPlaying() == true) {
PlaybackStateCompat.STATE_PLAYING PlaybackStateCompat.STATE_PLAYING
} else { } else {
PlaybackStateCompat.STATE_PAUSED PlaybackStateCompat.STATE_PAUSED
@@ -366,8 +396,12 @@ class PlaybackService : Service() {
private fun setPlaying(playing: Boolean) { private fun setPlaying(playing: Boolean) {
_uiState.value = _uiState.value.copy(isPlaying = playing) _uiState.value = _uiState.value.copy(isPlaying = playing)
player?.playWhenReady = playing if (playing) {
updatePlaybackStateFromPlayer() engine?.play()
} else {
engine?.pause()
}
updatePlaybackStateFromEngine()
refreshForegroundNotification() refreshForegroundNotification()
} }
@@ -404,20 +438,18 @@ class PlaybackService : Service() {
fun requestSeek(fraction: Float) { fun requestSeek(fraction: Float) {
if (queue.isEmpty()) return if (queue.isEmpty()) return
val p = player ?: return val e = engine ?: return
val durationMs = currentDurationMs() val durationMs = currentDurationMs()
val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong() val positionMs = (fraction.coerceIn(0f, 1f) * durationMs).toLong()
p.seekTo(positionMs) e.seekTo(positionMs)
updateProgressFromPlayer() updateProgressFromEngine()
} }
/** /**
* Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition]. * Milliseconds from the start of the current track — same timebase as the playback engine.
* May be called from any thread per Media3 [Player] contract. * Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe.
*/ */
fun getPlaybackPositionMs(): Long { fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs
return player?.currentPosition ?: 0L
}
private fun refreshForegroundNotification() { private fun refreshForegroundNotification() {
if (queue.isEmpty()) return if (queue.isEmpty()) return
@@ -494,7 +526,8 @@ class PlaybackService : Service() {
override fun onDestroy() { override fun onDestroy() {
positionPollJob?.cancel() positionPollJob?.cancel()
releasePlayer() positionCachePollJob?.cancel()
releaseEngine()
mediaSession.run { mediaSession.run {
isActive = false isActive = false
release() release()
@@ -532,12 +565,13 @@ 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). */ /** Fallback when the engine has not reported duration yet (from jukebox or default). */
val durationMsHint: Int, val durationMsHint: Int,
) )
companion object { companion object {
private const val UPDATE_INTERVAL_MS = 250L private const val UPDATE_INTERVAL_MS = 250L
private const val POSITION_CACHE_INTERVAL_MS = 20L
private const val DEFAULT_DURATION_HINT_MS = 180_000 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"

View File

@@ -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")
}

View File

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

View File

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

View File

@@ -115,6 +115,7 @@ fun LockstepAppNavHost(
NowPlayingRoute( NowPlayingRoute(
playlistId = playlistId, playlistId = playlistId,
playback = playback, playback = playback,
viewModel = viewModel,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }

View File

@@ -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 = {

View File

@@ -37,6 +37,7 @@ fun SettingsScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle() val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle()
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
@@ -82,6 +83,30 @@ fun SettingsScreen(
onCheckedChange = { viewModel.setAnnotationMode(it) }, 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,

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

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

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

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

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_bg">#E65100</color> <color name="ic_launcher_bg">#ffffff</color>
</resources> </resources>

View File

@@ -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_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">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_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_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_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_tap_area_label">Tap here on the beat</string>