feat: Now Playing screen MVP (nb. layout xml and Compose UI are two different sources)
This commit is contained in:
19
app/src/main/AndroidManifest.xml
Normal file
19
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LockstepPlayer">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LockstepPlayer">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
43
app/src/main/java/at/lockstep/player/MainActivity.kt
Normal file
43
app/src/main/java/at/lockstep/player/MainActivity.kt
Normal file
@@ -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 */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt
Normal file
154
app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt
Normal file
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_pause_24.xml
Normal file
10
app/src/main/res/drawable/ic_pause_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M6,19h4V5H6V19zM14,19h4V5h-4V19z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
10
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M8,5v14l11,-7L8,5z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_skip_next_24.xml
Normal file
10
app/src/main/res/drawable/ic_skip_next_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M6,18l8.5,-6L6,6V18zM16,6h2v12h-2V6z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_skip_previous_24.xml
Normal file
10
app/src/main/res/drawable/ic_skip_previous_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M6,6h2v12H6V6zM9.5,12l8.5,6V6L9.5,12z" />
|
||||
</vector>
|
||||
128
app/src/main/res/layout/activity_now_playing.xml
Normal file
128
app/src/main/res/layout/activity_now_playing.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Now Playing — View hierarchy for Android Studio Layout Editor / Design preview.
|
||||
The launcher still uses Compose (see MainActivity); wire this layout when adding a View-based screen.
|
||||
-->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingStart="@dimen/now_playing_horizontal_padding"
|
||||
android:paddingTop="@dimen/now_playing_vertical_padding"
|
||||
android:paddingEnd="@dimen/now_playing_horizontal_padding"
|
||||
android:paddingBottom="@dimen/now_playing_vertical_padding"
|
||||
tools:theme="@style/Theme.LockstepPlayer">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_track_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Example Track Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_track_artist"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_track_title"
|
||||
tools:text="Example Artist" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider_progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:stepSize="0.01"
|
||||
android:value="0.35"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_track_artist" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_position"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/slider_progress"
|
||||
tools:text="1:15" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/slider_progress"
|
||||
tools:text="3:35" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/barrier_after_times"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:constraint_referenced_ids="text_position,text_duration" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/button_play_pause"
|
||||
android:layout_width="@dimen/now_playing_play_button_size"
|
||||
android:layout_height="@dimen/now_playing_play_button_size"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/cd_play_pause"
|
||||
android:padding="16dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_next"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toEndOf="@id/button_previous"
|
||||
app:layout_constraintTop_toBottomOf="@id/barrier_after_times"
|
||||
app:srcCompat="@drawable/ic_play_arrow_24" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/button_previous"
|
||||
android:layout_width="@dimen/now_playing_transport_button_size"
|
||||
android:layout_height="@dimen/now_playing_transport_button_size"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/cd_previous_track"
|
||||
android:padding="14dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintBottom_toBottomOf="@id/button_play_pause"
|
||||
app:layout_constraintEnd_toStartOf="@id/button_play_pause"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/button_play_pause"
|
||||
app:srcCompat="@drawable/ic_skip_previous_24" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/button_next"
|
||||
android:layout_width="@dimen/now_playing_transport_button_size"
|
||||
android:layout_height="@dimen/now_playing_transport_button_size"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/cd_next_track"
|
||||
android:padding="14dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintBottom_toBottomOf="@id/button_play_pause"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/button_play_pause"
|
||||
app:layout_constraintTop_toTopOf="@id/button_play_pause"
|
||||
app:srcCompat="@drawable/ic_skip_next_24" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
7
app/src/main/res/values/dimens.xml
Normal file
7
app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="now_playing_horizontal_padding">24dp</dimen>
|
||||
<dimen name="now_playing_vertical_padding">16dp</dimen>
|
||||
<dimen name="now_playing_transport_button_size">80dp</dimen>
|
||||
<dimen name="now_playing_play_button_size">104dp</dimen>
|
||||
</resources>
|
||||
7
app/src/main/res/values/strings.xml
Normal file
7
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Lockstep</string>
|
||||
<string name="cd_previous_track">Previous track</string>
|
||||
<string name="cd_play_pause">Play or pause</string>
|
||||
<string name="cd_next_track">Next track</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.LockstepPlayer" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user