Compare commits
6 Commits
f7c5b932e9
...
feature/ru
| Author | SHA1 | Date | |
|---|---|---|---|
| e823fc9d94 | |||
| 965bec72d2 | |||
| 922cc5d82a | |||
| 88db4ad18d | |||
| a10102c10a | |||
| 19bbccf244 |
4
BUGS.md
@@ -1,6 +1,6 @@
|
||||
# Bugs
|
||||
|
||||
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||
X> annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||
|
||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
## Nice-to
|
||||
|
||||
- beat metadata is handled by the app, currently. The handling should move into "jukebox".
|
||||
|
||||
- add a test for /playlists/<playlist_id> but obfuscate the IDs and user id
|
||||
|
||||
- TrackFileMatching, scoreAgainstLocalHints
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LockstepPlayer">
|
||||
<activity
|
||||
|
||||
@@ -192,7 +192,7 @@ class LockstepViewModel(
|
||||
context = appContext,
|
||||
sessionFolder = sessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
contentId = contentId,
|
||||
title = event.title,
|
||||
artist = event.artist,
|
||||
@@ -207,7 +207,7 @@ class LockstepViewModel(
|
||||
BeatAnnotationStorage.writeBeatsFile(
|
||||
context = appContext,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
contentId = contentId,
|
||||
title = event.title,
|
||||
artist = event.artist,
|
||||
@@ -278,7 +278,7 @@ class LockstepViewModel(
|
||||
writeRunDataAndRecordMetadata(
|
||||
runSessionFolder = runSessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = event.queueIndex,
|
||||
trackQueueIndex0Based = event.playlistPosition,
|
||||
trackId = event.trackId,
|
||||
metaContentUri = meta,
|
||||
title = event.title,
|
||||
@@ -296,7 +296,7 @@ class LockstepViewModel(
|
||||
trackId: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
queueIndex: Int,
|
||||
playlistPosition: Int,
|
||||
snapshot: RunTrackDataSnapshot,
|
||||
) {
|
||||
if (snapshot.isEmpty()) {
|
||||
@@ -308,7 +308,7 @@ class LockstepViewModel(
|
||||
writeRunDataAndRecordMetadata(
|
||||
runSessionFolder = runSessionFolder,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = queueIndex,
|
||||
trackQueueIndex0Based = playlistPosition,
|
||||
trackId = trackId,
|
||||
metaContentUri = meta,
|
||||
title = title,
|
||||
@@ -453,6 +453,22 @@ class LockstepViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Set before popping Now Playing; [consumeSuppressNextLibraryMetadataSync] skips one Library batch sync. */
|
||||
@Volatile
|
||||
private var suppressNextLibraryMetadataSync = false
|
||||
|
||||
fun suppressNextLibraryMetadataSync() {
|
||||
suppressNextLibraryMetadataSync = true
|
||||
}
|
||||
|
||||
fun consumeSuppressNextLibraryMetadataSync(): Boolean {
|
||||
if (!suppressNextLibraryMetadataSync) {
|
||||
return false
|
||||
}
|
||||
suppressNextLibraryMetadataSync = false
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun syncPendingMetadata(): String? {
|
||||
val token = spotifyAccessToken.value
|
||||
if (token.isNullOrBlank()) {
|
||||
|
||||
@@ -110,6 +110,7 @@ class PlaybackService : Service() {
|
||||
title = item.title,
|
||||
artist = item.artist,
|
||||
queueIndex = i,
|
||||
playlistPosition = item.playlistPosition,
|
||||
queueSize = queue.size,
|
||||
reason = reason,
|
||||
),
|
||||
@@ -265,6 +266,7 @@ class PlaybackService : Service() {
|
||||
artist = row.artistName ?: "—",
|
||||
localUri = Uri.parse(uriStr),
|
||||
durationMsHint = hint,
|
||||
playlistPosition = row.position,
|
||||
)
|
||||
}
|
||||
index = 0
|
||||
@@ -300,6 +302,7 @@ class PlaybackService : Service() {
|
||||
isPlaying = _uiState.value.isPlaying,
|
||||
currentTrackId = item.id,
|
||||
currentQueueIndex = index,
|
||||
currentPlaylistPosition = item.playlistPosition,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updateProgressFromEngine()
|
||||
@@ -367,6 +370,8 @@ class PlaybackService : Service() {
|
||||
durationSeconds = durationSec,
|
||||
currentTrackId = queue.getOrNull(index)?.id ?: _uiState.value.currentTrackId,
|
||||
currentQueueIndex = index,
|
||||
currentPlaylistPosition =
|
||||
queue.getOrNull(index)?.playlistPosition ?: _uiState.value.currentPlaylistPosition,
|
||||
queueSize = queue.size,
|
||||
)
|
||||
updatePlaybackStateFromEngine()
|
||||
@@ -566,6 +571,8 @@ class PlaybackService : Service() {
|
||||
val isPlaying: Boolean,
|
||||
val currentTrackId: String?,
|
||||
val currentQueueIndex: Int,
|
||||
/** 0-based position in the full Spotify playlist for the current track. */
|
||||
val currentPlaylistPosition: Int,
|
||||
val queueSize: Int,
|
||||
) {
|
||||
companion object {
|
||||
@@ -578,6 +585,7 @@ class PlaybackService : Service() {
|
||||
isPlaying = false,
|
||||
currentTrackId = null,
|
||||
currentQueueIndex = 0,
|
||||
currentPlaylistPosition = 0,
|
||||
queueSize = 0,
|
||||
)
|
||||
}
|
||||
@@ -590,6 +598,8 @@ class PlaybackService : Service() {
|
||||
val localUri: Uri?,
|
||||
/** Fallback when the engine has not reported duration yet (from jukebox or default). */
|
||||
val durationMsHint: Int,
|
||||
/** 0-based position in the full Spotify playlist. */
|
||||
val playlistPosition: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,8 +8,10 @@ data class TrackBoundaryEvent(
|
||||
val trackId: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
/** 0-based index in the current play queue when this track was current. */
|
||||
/** 0-based index in the paired-only play queue when this track was current. */
|
||||
val queueIndex: Int,
|
||||
/** 0-based position in the full Spotify playlist (used for beat/run-data file slots). */
|
||||
val playlistPosition: Int,
|
||||
val queueSize: Int,
|
||||
val reason: TrackBoundaryReason,
|
||||
)
|
||||
|
||||
@@ -91,17 +91,21 @@ fun AnnotationRoute(
|
||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
|
||||
LaunchedEffect(playback, playlistId, annotationSessionFolder) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
service.trackBoundaryEvents.collect { event ->
|
||||
val snapshot = beatTimesMs.toList()
|
||||
beatTimesMs.clear()
|
||||
viewModel.persistBeatAnnotation(
|
||||
playlistId,
|
||||
playlistDisplayName,
|
||||
name,
|
||||
annotationSessionFolder,
|
||||
event,
|
||||
snapshot,
|
||||
|
||||
@@ -116,7 +116,10 @@ fun LockstepAppNavHost(
|
||||
playlistId = playlistId,
|
||||
playback = playback,
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
viewModel.suppressNextLibraryMetadataSync()
|
||||
navController.popBackStack()
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
||||
@@ -2,6 +2,7 @@ package at.lockstep.player.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -169,6 +170,7 @@ fun NowPlayingRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
|
||||
val collector = remember { RunDataCollector(context) }
|
||||
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
|
||||
@@ -194,7 +196,7 @@ fun NowPlayingRoute(
|
||||
}
|
||||
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||||
var currentTrackId by remember { mutableStateOf<String?>(null) }
|
||||
var currentQueueIndex by remember { mutableIntStateOf(0) }
|
||||
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
|
||||
|
||||
var ui by remember {
|
||||
mutableStateOf(
|
||||
@@ -210,18 +212,16 @@ fun NowPlayingRoute(
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId, playlistDisplayName) {
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName)
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
currentTrackId = p.currentTrackId
|
||||
currentQueueIndex = p.currentQueueIndex
|
||||
currentPlaylistPosition = p.currentPlaylistPosition
|
||||
ui =
|
||||
NowPlayingUiState(
|
||||
title = p.title,
|
||||
@@ -255,12 +255,14 @@ fun NowPlayingRoute(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) {
|
||||
LaunchedEffect(collectRunData, playback, playlistId) {
|
||||
if (!collectRunData) return@LaunchedEffect
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
val name = viewModel.getPlaylistDisplayName(playlistId)
|
||||
playlistDisplayName = name
|
||||
service.trackBoundaryEvents.collect { event ->
|
||||
val snapshot = collector.snapshotAndClear()
|
||||
viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot)
|
||||
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +285,7 @@ fun NowPlayingRoute(
|
||||
trackId = trackId,
|
||||
title = ui.title,
|
||||
artist = ui.artist,
|
||||
queueIndex = currentQueueIndex,
|
||||
playlistPosition = currentPlaylistPosition,
|
||||
snapshot = snapshot,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,11 @@ fun LibraryScreen(
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (token.isNullOrBlank()) return@LaunchedEffect
|
||||
val skipMetadataSync = viewModel.consumeSuppressNextLibraryMetadataSync()
|
||||
val errors =
|
||||
listOfNotNull(
|
||||
viewModel.syncJukeboxIfToken(),
|
||||
viewModel.syncPendingMetadata(),
|
||||
if (skipMetadataSync) null else viewModel.syncPendingMetadata(),
|
||||
)
|
||||
if (errors.isNotEmpty()) {
|
||||
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
|
||||
|
||||
102
app/src/main/java/at/lockstep/player/util/NtpClient.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package at.lockstep.player.util
|
||||
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
object NtpClient {
|
||||
|
||||
private const val NTP_HOST = "pool.ntp.org"
|
||||
private const val NTP_PORT = 123
|
||||
private const val NTP_PACKET_SIZE = 48
|
||||
private const val TIMEOUT_MS = 5000
|
||||
private const val NTP_EPOCH_OFFSET_SECONDS = 2208988800L
|
||||
|
||||
/**
|
||||
* Queries [NTP_HOST] and returns how many seconds the system clock is ahead of the server.
|
||||
* Positive = local clock is fast; negative = local clock is slow.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
fun clockOffsetSeconds(): Double {
|
||||
val socket = DatagramSocket()
|
||||
try {
|
||||
socket.soTimeout = TIMEOUT_MS
|
||||
|
||||
val requestBuffer = ByteArray(NTP_PACKET_SIZE)
|
||||
requestBuffer[0] = 0x1B // LI=0, VN=3, mode=client
|
||||
|
||||
val t1Millis = System.currentTimeMillis()
|
||||
writeNtpTimestamp(requestBuffer, 40, t1Millis)
|
||||
|
||||
val address = InetAddress.getByName(NTP_HOST)
|
||||
val requestPacket = DatagramPacket(requestBuffer, requestBuffer.size, address, NTP_PORT)
|
||||
socket.send(requestPacket)
|
||||
|
||||
val responseBuffer = ByteArray(NTP_PACKET_SIZE)
|
||||
val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size)
|
||||
|
||||
val errorRef = AtomicReference<Exception?>(null)
|
||||
val receiveTimeRef = AtomicReference<Long?>(null)
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
Thread {
|
||||
try {
|
||||
socket.receive(responsePacket)
|
||||
receiveTimeRef.set(System.currentTimeMillis())
|
||||
} catch (e: Exception) {
|
||||
errorRef.set(e)
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}.start()
|
||||
|
||||
if (!latch.await((TIMEOUT_MS + 1000).toLong(), TimeUnit.MILLISECONDS)) {
|
||||
throw SocketTimeoutException("NTP request timed out")
|
||||
}
|
||||
errorRef.get()?.let { throw it }
|
||||
|
||||
val t4Millis = receiveTimeRef.get()
|
||||
?: throw IllegalStateException("NTP response received without timestamp")
|
||||
|
||||
val t1 = t1Millis / 1000.0
|
||||
val t2 = ntpBytesToSeconds(responseBuffer, 32)
|
||||
val t3 = ntpBytesToSeconds(responseBuffer, 40)
|
||||
val t4 = t4Millis / 1000.0
|
||||
|
||||
return ((t1 - t2) + (t4 - t3)) / 2.0
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ntpBytesToSeconds(buffer: ByteArray, offset: Int): Double {
|
||||
val seconds = readUint32(buffer, offset)
|
||||
val fraction = readUint32(buffer, offset + 4)
|
||||
return (seconds - NTP_EPOCH_OFFSET_SECONDS) + fraction / 4294967296.0
|
||||
}
|
||||
|
||||
private fun writeNtpTimestamp(buffer: ByteArray, offset: Int, millis: Long) {
|
||||
val unixSeconds = millis / 1000.0
|
||||
val ntpSeconds = (unixSeconds + NTP_EPOCH_OFFSET_SECONDS).toLong()
|
||||
val fraction = ((unixSeconds - unixSeconds.toLong()) * 4294967296.0).toLong()
|
||||
writeUint32(buffer, offset, ntpSeconds)
|
||||
writeUint32(buffer, offset + 4, fraction)
|
||||
}
|
||||
|
||||
private fun readUint32(buffer: ByteArray, offset: Int): Long =
|
||||
((buffer[offset].toLong() and 0xFF) shl 24) or
|
||||
((buffer[offset + 1].toLong() and 0xFF) shl 16) or
|
||||
((buffer[offset + 2].toLong() and 0xFF) shl 8) or
|
||||
(buffer[offset + 3].toLong() and 0xFF)
|
||||
|
||||
private fun writeUint32(buffer: ByteArray, offset: Int, value: Long) {
|
||||
buffer[offset] = ((value shr 24) and 0xFF).toByte()
|
||||
buffer[offset + 1] = ((value shr 16) and 0xFF).toByte()
|
||||
buffer[offset + 2] = ((value shr 8) and 0xFF).toByte()
|
||||
buffer[offset + 3] = (value and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
66
media/shoe_logo4.svg
Normal file
|
After Width: | Height: | Size: 51 KiB |
324
plans/runbuddy-mode.md
Normal file
@@ -0,0 +1,324 @@
|
||||
---
|
||||
name: Runbuddy Mode Plan
|
||||
overview: "Add 1:1 RunBuddy sessions: leader creates a public session on the server, follower picks it from an in-app billboard, and both stay in sync via WebSocket events timestamped with NTP-corrected wall clock. Both devices use the same Spotify playlist and local MP3 pairings."
|
||||
todos:
|
||||
- id: server-schema-rest
|
||||
content: Add runbuddy_sessions/participants tables + REST endpoints (create, billboard, join, end, leave) to lockstep-2-api
|
||||
status: pending
|
||||
- id: server-websocket
|
||||
content: Add WebSocket room relay with token auth, snapshot persistence, and leader→follower fan-out
|
||||
status: pending
|
||||
- id: android-clients
|
||||
content: Implement RunBuddyApiClient, RunBuddyWebSocketClient (OkHttp), RunBuddyClock wrapping NtpClient
|
||||
status: pending
|
||||
- id: sync-protocol
|
||||
content: Define JSON event schema and follower drift correction math in RunBuddyCoordinator
|
||||
status: pending
|
||||
- id: playback-hooks
|
||||
content: Extend PlaybackService with attachRunBuddy, applyRemoteTransport, leader emit hooks on transport changes
|
||||
status: pending
|
||||
- id: prejoin-validation
|
||||
content: Follower pairing validation against session playlistId + pairedTrackCount before join
|
||||
status: pending
|
||||
- id: ui-flows
|
||||
content: Add RunBuddy billboard screen, leader start/waiting UI, Now Playing role banner and follower control lockout
|
||||
status: pending
|
||||
- id: e2e-test
|
||||
content: "Two-device test: create/join, play/pause/seek/skip/track-change, verify <50ms perceived drift while running"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# RunBuddy Mode — Implementation Plan
|
||||
|
||||
## Scope (confirmed)
|
||||
|
||||
- **1 leader + 1 follower** per session (session locks when follower joins)
|
||||
- **Same Spotify playlist + same local MP3 pairings** on both devices
|
||||
- **Leader is source of truth** for playback transport and position
|
||||
- **Position sync target:** millisecond-level via [`NtpClient.kt`](app/src/main/java/at/lockstep/player/util/NtpClient.kt) + timestamped realtime events
|
||||
|
||||
## Current baseline
|
||||
|
||||
| Layer | Today | Gap |
|
||||
|-------|-------|-----|
|
||||
| Playback | [`PlaybackService`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) owns queue, play/pause/seek/skip locally | No remote command ingress |
|
||||
| Networking | REST only ([`MetadataSyncClient`](app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt), jukebox) | No sessions, no WebSocket |
|
||||
| Server | Flask monolith [`lockstep-2-api/api.py`](lockstep-2-api/api.py) | No session model, no realtime |
|
||||
| Clock | `NtpClient` exists but unused | Wire into session lifecycle |
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Leader as LeaderApp
|
||||
participant Server as api.lockstep.at
|
||||
participant Follower as FollowerApp
|
||||
participant NTP as pool.ntp.org
|
||||
|
||||
Leader->>NTP: NtpClient (offset)
|
||||
Leader->>Server: POST /runbuddy/sessions
|
||||
Server-->>Leader: sessionId, joinCode, wsToken
|
||||
Leader->>Server: WS connect (leader)
|
||||
Follower->>Server: GET /runbuddy/sessions (billboard)
|
||||
Follower->>Server: POST /runbuddy/sessions/{id}/join
|
||||
Follower->>NTP: NtpClient (offset)
|
||||
Follower->>Server: WS connect (follower)
|
||||
Leader->>Server: transport + position events (correctedMs)
|
||||
Server->>Follower: fan-out events
|
||||
Follower->>Follower: apply to PlaybackService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Server — session model + billboard + WebSocket
|
||||
|
||||
Extend [`lockstep-2-api/api.py`](lockstep-2-api/api.py) (or split into `runbuddy.py` imported by `api.py` to keep the monolith manageable).
|
||||
|
||||
### SQLite tables
|
||||
|
||||
**`runbuddy_sessions`**
|
||||
- `id` (UUID, PK)
|
||||
- `host_spotify_user_id`
|
||||
- `playlist_id`, `playlist_name`
|
||||
- `paired_track_count` (leader-reported, for follower pre-check)
|
||||
- `status` (`waiting` | `active` | `ended`)
|
||||
- `created_at`, `expires_at` (TTL, e.g. 2h idle)
|
||||
|
||||
**`runbuddy_participants`**
|
||||
- `session_id`, `role` (`leader` | `follower`)
|
||||
- `spotify_user_id`, `joined_at`
|
||||
- Unique constraint: one follower per session
|
||||
|
||||
**`runbuddy_ws_tokens`**
|
||||
- Short-lived join token per participant (avoid putting Spotify bearer in WS query string long-term)
|
||||
|
||||
### REST endpoints (all `@require_auth` with existing Bearer Spotify token)
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `POST` | `/runbuddy/sessions` | Leader creates session. Body: `{ playlistId, playlistName, pairedTrackCount }`. Returns `{ sessionId, joinCode, wsUrl, wsToken, snapshot }`. |
|
||||
| `GET` | `/runbuddy/sessions` | **Billboard**: list `status=waiting` public sessions (host display name, playlist name, paired count, created_at). |
|
||||
| `GET` | `/runbuddy/sessions/{id}` | Session snapshot for late join / reconnect. |
|
||||
| `POST` | `/runbuddy/sessions/{id}/join` | Follower joins (409 if full). Returns `{ wsUrl, wsToken, snapshot }`. |
|
||||
| `POST` | `/runbuddy/sessions/{id}/end` | Leader ends session. |
|
||||
| `POST` | `/runbuddy/sessions/{id}/leave` | Follower leaves. |
|
||||
|
||||
**Snapshot** (stored server-side, updated by leader over WS) includes current transport state:
|
||||
|
||||
```json
|
||||
{
|
||||
"playlistId": "...",
|
||||
"playlistPosition": 3,
|
||||
"trackId": "spotify:track:...",
|
||||
"positionMs": 45123,
|
||||
"isPlaying": true,
|
||||
"correctedMs": 1717171717171
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket channel
|
||||
|
||||
Add a realtime layer to the Flask deployment. Practical options:
|
||||
|
||||
- **Recommended for MVP:** [`flask-sock`](https://github.com/miguelgrinberg/flask-sock) (simple, works with existing Flask app) or a small **sidecar ASGI service** if production WSGI cannot hold long-lived connections
|
||||
- Route: `GET /runbuddy/ws?sessionId=&token=`
|
||||
- In-memory room registry keyed by `sessionId` (acceptable for 1:1 MVP; document Redis upgrade path if scaling)
|
||||
|
||||
**Server responsibilities:**
|
||||
- Authenticate token → session + role
|
||||
- Relay JSON messages leader → follower (follower messages limited to `ping`, `leave`, optional `ready`)
|
||||
- Persist latest snapshot on leader `state` / `position` messages
|
||||
- Close room and set `status=ended` on leader disconnect or explicit end
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Add session TTL cleanup cron or lazy expiry on billboard GET
|
||||
- Rate-limit session creation (already flagged in [`BUGS.md`](BUGS.md))
|
||||
- WebSocket requires production config change (proxy timeout, upgrade headers) — document in deploy notes
|
||||
|
||||
---
|
||||
|
||||
## 2. Sync protocol — NTP + event timestamps
|
||||
|
||||
### Shared clock helper (new Android class)
|
||||
|
||||
[`RunBuddyClock`](app/src/main/java/at/lockstep/player/runbuddy/RunBuddyClock.kt) (proposed):
|
||||
|
||||
```kotlin
|
||||
// offsetMs = local ahead of NTP (from NtpClient)
|
||||
fun correctedNowMs(): Long = System.currentTimeMillis() - offsetMs
|
||||
```
|
||||
|
||||
- Call `NtpClient.clockOffsetSeconds()` on **session join** (background thread) and optionally **refresh every ~5 min** during active session
|
||||
- Both peers use the same reference (NTP), not server wall clock — satisfies your requirement and avoids adding a server time endpoint for MVP
|
||||
|
||||
### Event types (WebSocket JSON)
|
||||
|
||||
| Type | Sender | Fields | Purpose |
|
||||
|------|--------|--------|---------|
|
||||
| `transport` | leader | `action`: `play`/`pause`/`seek`/`skip_next`/`skip_prev`/`track_change`, `correctedMs`, `positionMs`, `playlistPosition`, `trackId` | Immediate state changes |
|
||||
| `position` | leader | `correctedMs`, `positionMs`, `isPlaying`, `playlistPosition`, `trackId` | Heartbeat every **250ms** (match existing [`UPDATE_INTERVAL_MS`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt)) |
|
||||
| `snapshot` | server | full snapshot | On follower connect / reconnect |
|
||||
| `session_end` | server | reason | Tear down |
|
||||
|
||||
All timestamps in events use **`correctedMs`** (NTP-aligned epoch millis).
|
||||
|
||||
### Follower position correction
|
||||
|
||||
On each `position` or `transport` event:
|
||||
|
||||
```
|
||||
targetMs = event.positionMs + (event.isPlaying ? followerClock.correctedNowMs() - event.correctedMs : 0)
|
||||
drift = targetMs - localEngine.getCurrentPositionMs()
|
||||
if |drift| > 30ms → engine.seekTo(targetMs)
|
||||
if track mismatch → publishCurrentTrack by playlistPosition/trackId first, then seek
|
||||
```
|
||||
|
||||
- **30ms threshold** is a starting point; tune with real devices
|
||||
- When paused, do not extrapolate — hold `positionMs` exactly
|
||||
- Ignore heartbeats while a transport event is being applied (short debounce)
|
||||
|
||||
### Leader behavior
|
||||
|
||||
- Local UI actions (`requestTogglePause`, `requestSeek`, skip) **also emit** corresponding `transport` events before/after applying locally
|
||||
- Position heartbeats read `getPlaybackPositionMs()` + `RunBuddyClock.correctedNowMs()`
|
||||
- Leader local controls remain enabled; follower controls **disabled or no-op** (show "following leader" UI)
|
||||
|
||||
---
|
||||
|
||||
## 3. Android — new modules and PlaybackService integration
|
||||
|
||||
### New packages / files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `data/RunBuddyApiClient.kt` | REST: create, list, join, end, leave |
|
||||
| `runbuddy/RunBuddyWebSocketClient.kt` | OkHttp WebSocket (already on classpath) with reconnect + backoff |
|
||||
| `runbuddy/RunBuddyClock.kt` | NTP offset cache |
|
||||
| `runbuddy/RunBuddyCoordinator.kt` | Session lifecycle, role, event encode/decode, snapshot apply |
|
||||
| `runbuddy/RunBuddySessionState.kt` | `Idle`, `Leading(sessionId)`, `Following(sessionId)` |
|
||||
|
||||
Wire clients from [`LockstepApplication`](app/src/main/java/at/lockstep/player/LockstepApplication.kt) (shared `OkHttpClient`).
|
||||
|
||||
### PlaybackService changes ([`PlaybackService.kt`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt))
|
||||
|
||||
Add a **`RunBuddyCoordinator` reference** (injected via new binder methods or constructor set from NavHost when session starts):
|
||||
|
||||
**New public API:**
|
||||
- `attachRunBuddy(coordinator: RunBuddyCoordinator)`
|
||||
- `detachRunBuddy()`
|
||||
- `applyRemoteTransport(...)` — internal mirror of play/pause/seek/skip/track change **without** re-emitting leader events
|
||||
- `isRunBuddyFollower(): Boolean` — UI uses this to disable controls
|
||||
|
||||
**Hook points (existing methods):**
|
||||
|
||||
```403:452:app/src/main/java/at/lockstep/player/playback/PlaybackService.kt
|
||||
private fun setPlaying(playing: Boolean) { ... }
|
||||
private fun skipDelta(delta: Int) { ... }
|
||||
fun requestSeek(fraction: Float) { ... }
|
||||
```
|
||||
|
||||
- Leader path: after local mutation, notify coordinator → WS send
|
||||
- Follower path: coordinator calls `applyRemoteTransport` instead of user `request*` methods
|
||||
|
||||
**Track identity:** sync on `currentPlaylistPosition` + `currentTrackId` (not `currentQueueIndex`) to stay stable across paired-only queue indexing — aligns with existing [`PlaybackUiState`](app/src/main/java/at/lockstep/player/playback/PlaybackService.kt) fields and avoids the unpaired-track index bug called out in [`BUGS.md`](BUGS.md).
|
||||
|
||||
### Pre-join validation (follower)
|
||||
|
||||
Before `POST .../join`:
|
||||
1. User must be signed in (existing Spotify token)
|
||||
2. Local `pairingDao.countPaired(playlistId) >= session.pairedTrackCount` (and ideally equal)
|
||||
3. Optionally verify each Spotify track id in leader snapshot queue is paired locally — fail fast with clear error if not
|
||||
|
||||
### ViewModel ([`LockstepViewModel.kt`](app/src/main/java/at/lockstep/player/LockstepViewModel.kt))
|
||||
|
||||
Add thin session orchestration (not playback logic):
|
||||
- `createRunBuddySession(playlistId)`
|
||||
- `listPublicSessions()`
|
||||
- `joinRunBuddySession(sessionId)`
|
||||
- `endRunBuddySession()`
|
||||
- Expose `runBuddyState: StateFlow<RunBuddySessionState>` for UI
|
||||
|
||||
Keep playback sync inside `PlaybackService` + coordinator (consistent with [`DESIGN.md`](DESIGN.md) foreground-service ownership).
|
||||
|
||||
---
|
||||
|
||||
## 4. UI flows
|
||||
|
||||
### Navigation ([`Routes.kt`](app/src/main/java/at/lockstep/player/ui/navigation/Routes.kt))
|
||||
|
||||
Add routes:
|
||||
- `runbuddy/billboard` — public session list
|
||||
- Reuse `nowPlaying/{playlistId}` for both roles once connected
|
||||
|
||||
### Leader flow
|
||||
|
||||
1. Library → select playlist (must be fully paired)
|
||||
2. **"Start RunBuddy"** action (new button on Library row or playlist detail)
|
||||
3. Create session → show join code + "Waiting for buddy…"
|
||||
4. On follower join → navigate to Now Playing, start playlist via existing `startPlaylistPlayback()`
|
||||
5. Coordinator attaches as **leader**; heartbeats begin
|
||||
|
||||
### Follower flow
|
||||
|
||||
1. New entry point: **RunBuddy** from Library top bar or Settings
|
||||
2. [`RunBuddyBillboardScreen`](app/src/main/java/at/lockstep/player/ui/runbuddy/RunBuddyBillboardScreen.kt) — poll or pull-to-refresh `GET /runbuddy/sessions`
|
||||
3. Tap session → validate pairings → join → navigate to Now Playing + start same playlist
|
||||
4. Coordinator attaches as **follower**; apply snapshot immediately
|
||||
|
||||
### Now Playing UI ([`NowPlayingScreen.kt`](app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt))
|
||||
|
||||
- Banner: "Leading RunBuddy" / "Following [host name]"
|
||||
- Follower: disable play/pause/skip/seek slider (or show read-only progress)
|
||||
- Leader: **End session** button
|
||||
- Show sync status indicator (clock synced / WS connected / drift ms for debug builds)
|
||||
|
||||
### Settings ([`SettingsScreen.kt`](app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt))
|
||||
|
||||
Optional toggle: **"Enable RunBuddy mode"** (feature flag) — can defer if always-on is fine for MVP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested implementation phases
|
||||
|
||||
### Phase A — Server foundation
|
||||
- DB migration + REST CRUD + billboard list
|
||||
- Manual test with curl/Postman
|
||||
|
||||
### Phase B — WebSocket relay
|
||||
- Token auth, leader→follower fan-out, snapshot persistence
|
||||
- Test with `websocat` or a minimal HTML client
|
||||
|
||||
### Phase C — Android clients
|
||||
- `RunBuddyApiClient`, `RunBuddyWebSocketClient`, `RunBuddyClock`
|
||||
- Unit-test event parsing + follower drift math (pure Kotlin)
|
||||
|
||||
### Phase D — Playback integration
|
||||
- `RunBuddyCoordinator` + `PlaybackService` hooks
|
||||
- End-to-end two-emulator or two-device test
|
||||
|
||||
### Phase E — UI + polish
|
||||
- Billboard + leader start/waiting screens
|
||||
- Reconnect (follower re-joins, applies snapshot, resumes heartbeats)
|
||||
- Session expiry, error toasts, 409 when session full
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| ExoPlayer seek latency causes drift | Periodic 250ms heartbeats + 30ms threshold; pause extrapolation |
|
||||
| NTP blocked on mobile network | Fail gracefully; show "clock sync failed" and block join |
|
||||
| Flask WS in production | Verify reverse-proxy WS upgrade; fallback sidecar if needed |
|
||||
| Unpaired-track index bug | Sync on `playlistPosition`/`trackId`; fix BUGS.md issue in parallel |
|
||||
| Leader backgrounded | Foreground service already running; WS kept alive in service process |
|
||||
| Pairing mismatch | Pre-join validation against `pairedTrackCount` + track ids |
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (MVP)
|
||||
|
||||
- Group runs (1:N)
|
||||
- Cross-playlist or streaming without local MP3s
|
||||
- Syncing run-data sensor JSON over WS (only playback)
|
||||
- Server-side beat/metadata sharing per session (reuse existing per-user metadata APIs later)
|
||||
53
scripts/generate_launcher_icons.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Downscale launcher icon PNGs from mipmap-xxxhdpi masters to lower densities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RES = ROOT / "app" / "src" / "main" / "res"
|
||||
SOURCE_FOLDER = "mipmap-xxxhdpi"
|
||||
|
||||
SOURCES = {
|
||||
"ic_launcher.png": {
|
||||
"mipmap-xxxhdpi": 192,
|
||||
"mipmap-xxhdpi": 144,
|
||||
"mipmap-xhdpi": 96,
|
||||
"mipmap-hdpi": 72,
|
||||
"mipmap-mdpi": 48,
|
||||
},
|
||||
"ic_launcher_foreground.png": {
|
||||
"mipmap-xxxhdpi": 432,
|
||||
"mipmap-xxhdpi": 324,
|
||||
"mipmap-xhdpi": 216,
|
||||
"mipmap-hdpi": 162,
|
||||
"mipmap-mdpi": 108,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for filename, sizes in SOURCES.items():
|
||||
source_path = RES / SOURCE_FOLDER / filename
|
||||
source = Image.open(source_path)
|
||||
expected = sizes[SOURCE_FOLDER]
|
||||
if source.size != (expected, expected):
|
||||
raise RuntimeError(
|
||||
f"{source_path} is {source.size[0]}x{source.size[1]}, expected {expected}x{expected}"
|
||||
)
|
||||
|
||||
for folder, size in sizes.items():
|
||||
if folder == SOURCE_FOLDER:
|
||||
continue
|
||||
out_dir = RES / folder
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
resized = source.resize((size, size), Image.Resampling.LANCZOS)
|
||||
resized.save(out_dir / filename)
|
||||
print(f"Wrote {folder}/{filename} ({size}x{size})")
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||