diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..beab9c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Gradle / Android +.gradle/ +build/ +**/build/ +local.properties +*.iml +.idea/ +.DS_Store +captures/ +*.apk +*.ap_ +*.aab diff --git a/README.md b/README.md index 39e3dea..b179778 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,8 @@ Android prototype: music playback adapts to running pace (accelerometer + native | [SPECS.md](SPECS.md) | Product intent, MVP scope, **libpasada** DSP responsibilities, Oboe/JNI/MP3 FD assumptions, **jukebox** submodule role | | [DESIGN.md](DESIGN.md) | Android architecture decisions, foreground run service, JNI/state machine (`init` / `play` / `pause` / `resume` / `stop` / …), open UI questions | +**App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/). + +Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studio’s **jbr** under `/Applications/…` or `/c/Program Files/…`. + Submodule: [`jukebox/`](jukebox/). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3253a46 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "at.lockstep.player" + compileSdk = 35 + + defaultConfig { + applicationId = "at.lockstep.player" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "0.1" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.0") + + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..bbac389 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Add project-specific keep rules here. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..70bc777 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/at/lockstep/player/MainActivity.kt b/app/src/main/java/at/lockstep/player/MainActivity.kt new file mode 100644 index 0000000..b40fdfe --- /dev/null +++ b/app/src/main/java/at/lockstep/player/MainActivity.kt @@ -0,0 +1,43 @@ +package at.lockstep.player + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import at.lockstep.player.ui.NowPlayingScreen +import at.lockstep.player.ui.NowPlayingUiState + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + var progress by remember { mutableFloatStateOf(0f) } + var playing by remember { mutableStateOf(false) } + NowPlayingScreen( + state = NowPlayingUiState( + title = "No track", + artist = "—", + progress = progress, + durationSeconds = 180, + isPlaying = playing, + ), + onProgressChange = { progress = it }, + onPrevious = { /* TODO: service / JNI */ }, + onTogglePlayPause = { playing = !playing }, + onNext = { /* TODO: service / JNI */ }, + ) + } + } + } + } +} diff --git a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt new file mode 100644 index 0000000..b9f24a8 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt @@ -0,0 +1,154 @@ +package at.lockstep.player.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +data class NowPlayingUiState( + val title: String, + val artist: String, + /** 0f..1f until real timing is wired to Oboe / JNI. */ + val progress: Float, + /** Total track length in seconds (placeholder for UI formatting). */ + val durationSeconds: Int, + val isPlaying: Boolean, +) + +@Composable +fun NowPlayingScreen( + state: NowPlayingUiState, + onProgressChange: (Float) -> Unit, + onPrevious: () -> Unit, + onTogglePlayPause: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier, +) { + val elapsed = (state.progress * state.durationSeconds).toInt().coerceIn(0, state.durationSeconds) + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = state.title, + style = MaterialTheme.typography.headlineSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = state.artist, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Slider( + value = state.progress, + onValueChange = onProgressChange, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatMmSs(elapsed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatMmSs(state.durationSeconds), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious, modifier = Modifier.size(80.dp)) { + Icon(Icons.Default.SkipPrevious, contentDescription = "Previous track") + } + IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(104.dp)) { + Icon( + imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + modifier = Modifier.size(56.dp), + ) + } + IconButton(onClick = onNext, modifier = Modifier.size(80.dp)) { + Icon(Icons.Default.SkipNext, contentDescription = "Next track") + } + } + } + } +} + +private fun formatMmSs(totalSeconds: Int): String { + val s = totalSeconds.coerceAtLeast(0) + val m = s / 60 + val r = s % 60 + return "%d:%02d".format(m, r) +} + +@Preview(showBackground = true) +@Composable +private fun NowPlayingScreenPreview() { + MaterialTheme { + var progress by remember { mutableFloatStateOf(0.35f) } + var playing by remember { mutableStateOf(true) } + NowPlayingScreen( + state = NowPlayingUiState( + title = "Example Track Title", + artist = "Example Artist", + progress = progress, + durationSeconds = 215, + isPlaying = playing, + ), + onProgressChange = { progress = it }, + onPrevious = {}, + onTogglePlayPause = { playing = !playing }, + onNext = {}, + ) + } +} diff --git a/app/src/main/res/drawable/ic_pause_24.xml b/app/src/main/res/drawable/ic_pause_24.xml new file mode 100644 index 0000000..6626e5b --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_24.xml b/app/src/main/res/drawable/ic_play_arrow_24.xml new file mode 100644 index 0000000..54d5a9d --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_skip_next_24.xml b/app/src/main/res/drawable/ic_skip_next_24.xml new file mode 100644 index 0000000..fb97479 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_skip_previous_24.xml b/app/src/main/res/drawable/ic_skip_previous_24.xml new file mode 100644 index 0000000..9d1532a --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_now_playing.xml b/app/src/main/res/layout/activity_now_playing.xml new file mode 100644 index 0000000..8e7805e --- /dev/null +++ b/app/src/main/res/layout/activity_now_playing.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..d7fefbf --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + 24dp + 16dp + 80dp + 104dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..52f623c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Lockstep + Previous track + Play or pause + Next track + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9ad9f34 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +