feat: SongPickerActivity

This commit is contained in:
2026-03-19 12:27:38 +01:00
parent a8234005df
commit 02ebb17dc6
11 changed files with 385 additions and 3 deletions

View File

@@ -85,6 +85,8 @@ dependencies {
implementation libs.androidx.ui.graphics implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview implementation libs.androidx.ui.tooling.preview
implementation libs.androidx.material3 implementation libs.androidx.material3
implementation libs.androidx.recyclerview
implementation libs.androidx.appcompat
testImplementation libs.junit testImplementation libs.junit
androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core androidTestImplementation libs.androidx.espresso.core

View File

@@ -37,6 +37,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="at.lockstep.ui.SongPickerActivity" />
<service <service
android:name="at.lockstep.app.LstForegroundService" android:name="at.lockstep.app.LstForegroundService"
android:exported="false" android:exported="false"

View File

@@ -5,15 +5,37 @@ import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Button; import android.widget.Button;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import at.lockstep.R; import at.lockstep.R;
import at.lockstep.pb.PlaybackEngine; import at.lockstep.pb.PlaybackEngine;
import at.lockstep.ui.SongPickerActivity;
import android.widget.Toast;
public class MainActivity extends Activity { public class MainActivity extends AppCompatActivity {
private Button btnStart; private Button btnStart;
private Button btnStop; private Button btnStop;
private Button btnMediaStoreBenchmark; private Button btnMediaStoreBenchmark;
private Button btnPickSong;
private final ActivityResultLauncher<Intent> launcher;
public MainActivity() {
launcher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
String contentUri = data.getStringExtra("content_uri");
Toast.makeText(this, "Item clicked: " + contentUri, Toast.LENGTH_LONG).show();
}
}
}
);
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -23,6 +45,7 @@ public class MainActivity extends Activity {
btnStart = findViewById(R.id.btnStart); btnStart = findViewById(R.id.btnStart);
btnStop = findViewById(R.id.btnStop); btnStop = findViewById(R.id.btnStop);
btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark); btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark);
btnPickSong = findViewById(R.id.btnPickSong);
// TODO: handle clicking START button twice // TODO: handle clicking START button twice
btnStart.setOnClickListener(v -> btnStart.setOnClickListener(v ->
@@ -47,5 +70,10 @@ public class MainActivity extends Activity {
Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class); Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class);
startActivity(intent); startActivity(intent);
}); });
btnPickSong.setOnClickListener(v -> {
Intent intent = new Intent(this, SongPickerActivity.class);
launcher.launch(intent);
});
} }
} }

View File

@@ -0,0 +1,40 @@
package at.lockstep.ui;
/**
* Item DTO for song picker.
*/
public class SongItem {
private String title;
private String artist;
private String contentUri;
public SongItem(String title, String artist, String contentUri) {
this.title = title;
this.artist = artist;
this.contentUri = contentUri;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public String getContentUri() {
return contentUri;
}
public void setContentUri(String contentUri) {
this.contentUri = contentUri;
}
}

View File

@@ -0,0 +1,170 @@
package at.lockstep.ui;
import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.provider.MediaStore;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import at.lockstep.R;
/**
* Choose a song from the device library.
*/
public class SongPickerActivity extends Activity implements SongPickerAdapter.OnItemClickListener {
private List<SongItem> songs = new ArrayList<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_song_picker);
RecyclerView recyclerView = findViewById(R.id.recyclerView);
if (hasReadPermission()) {
loadSongList();
} else {
requestReadPermission();
}
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(new SongPickerAdapter(songs, this));
}
private static final int REQUEST_READ_PERMISSION = 1001;
@Override
public void onRequestPermissionsResult(
int requestCode,
String[] permissions,
int[] grantResults
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_READ_PERMISSION) {
boolean granted = grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (granted) {
loadSongList();
} else {
songs.add(new SongItem("Have No Songs - Permission denied", "Re-open app and try again.", null));
//resultTextView.setText("Permission denied.");
}
}
}
private boolean hasReadPermission() {
String permission;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permission = Manifest.permission.READ_MEDIA_AUDIO;
} else {
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
}
return ContextCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_GRANTED;
}
private void requestReadPermission() {
String permission;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permission = Manifest.permission.READ_MEDIA_AUDIO;
} else {
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
}
requestPermissions(
new String[]{ permission },
REQUEST_READ_PERMISSION
);
}
private void loadSongList() {
// TODO: cleanup, remove this list, etc
List<String> musicList = new ArrayList<>();
android.net.Uri collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[] {
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.DATA
};
String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0";
long start = SystemClock.elapsedRealtime();
//
// perform MediaStore query
//
try (android.database.Cursor cursor = getContentResolver().query(
collection,
projection,
selection,
null,
MediaStore.Audio.Media.TITLE + " ASC"
)) {
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID);
int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE);
int artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST);
int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA);
while (cursor.moveToNext()) {
String contentUri = cursor.getString(idColumn); // the content:// Uri for the MediaStore item
String title = cursor.getString(titleColumn);
String artist = cursor.getString(artistColumn);
String path = dataColumn != -1 ? cursor.getString(dataColumn) : null;
if (path != null) {
musicList.add(title + "\n" + path);
songs.add(new SongItem(title, artist, contentUri));
} else {
musicList.add(title + "\n[path unavailable]");
}
}
}
}
long elapsedMs = SystemClock.elapsedRealtime() - start;
StringBuilder header = new StringBuilder();
header.append("Found ").append(musicList.size()).append(" music files\n");
header.append("Query time: ").append(elapsedMs).append(" ms\n\n");
//resultTextView.setText(header.toString() + String.join("\n\n", musicList));
// 200 music files in 32 ms
// paths like "/storage/emulated/0/Download/...mp3"
}
@Override
public void onItemClick(SongItem item) {
if(item.getContentUri() == null) {
// clicked the prompt for missing permissions?
// try acquiring them again.
// nice-to: test this.
requestReadPermission();
return;
}
Intent data = new Intent();
data.putExtra("content_uri", item.getContentUri());
setResult(Activity.RESULT_OK, data);
finish();
}
}

View File

@@ -0,0 +1,66 @@
package at.lockstep.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import at.lockstep.R;
/**
* RecyclerView Adapter for song picker.
*/
public class SongPickerAdapter extends RecyclerView.Adapter<SongPickerAdapter.SongPickerViewHolder> {
private List<SongItem> songList;
private final OnItemClickListener listener;
public interface OnItemClickListener {
void onItemClick(SongItem item);
}
public SongPickerAdapter(List<SongItem> songList, OnItemClickListener listener) {
this.songList = songList;
this.listener = listener;
}
@NonNull
@Override
public SongPickerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.song_card, parent, false);
return new SongPickerViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SongPickerViewHolder holder, int position) {
SongItem songItem = songList.get(position);
holder.songTitle.setText(songItem.getTitle());
holder.songArtist.setText(songItem.getArtist());
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
listener.onItemClick(songItem);
}
});
}
@Override
public int getItemCount() {
return songList.size();
}
static class SongPickerViewHolder extends RecyclerView.ViewHolder {
TextView songTitle;
TextView songArtist;
public SongPickerViewHolder(@NonNull View itemView) {
super(itemView);
songTitle = itemView.findViewById(R.id.songTitle);
songArtist = itemView.findViewById(R.id.songArtist);
}
}
}

View File

@@ -24,4 +24,12 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Benchmark media store" /> android:text="Benchmark media store" />
<Button
android:id="@+id/btnPickSong"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Pick song" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@android:color/background_light"
tools:context=".ui.SongPickerActivity">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Song Picker"
android:textAlignment="center"
android:background="@android:color/darker_gray"
android:paddingTop="54sp"
android:paddingBottom="20sp"
android:textSize="24sp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="always"/>
</LinearLayout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="16dp"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground"
>
<!-- android:layout_height="96dp" -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/songTitle"
android:textSize="16sp"
android:text="Song Title"
android:textColor="@color/black"
android:maxLines="1"/>
<TextView
android:id="@+id/songArtist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/songTitle"
android:text="Artist name"
android:textSize="16sp"/>
</RelativeLayout>

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Lockstep" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="Theme.Lockstep" parent="android:Theme.Material.Light.NoActionBar" />
</resources> </resources>

View File

@@ -11,6 +11,8 @@ composeBom = "2024.04.01"
logbackAndroid = "2.0.0" logbackAndroid = "2.0.0"
oboe = "1.10.0" oboe = "1.10.0"
slf4jApi = "1.7.30" slf4jApi = "1.7.30"
recyclerview = "1.3.1"
appcompat = "1.7.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,6 +32,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
logback-android = { module = "com.github.tony19:logback-android", version.ref = "logbackAndroid" } logback-android = { module = "com.github.tony19:logback-android", version.ref = "logbackAndroid" }
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" } oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }