app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
raw · 18903 bytes
package net.astropenguin.loopdeck;
import android.app.*;
import android.bluetooth.*;
import android.content.pm.PackageManager;
import android.content.*;
import android.graphics.Color;
import android.net.Uri;
import android.os.*;
import android.text.InputType;
import android.view.*;
import android.widget.*;
import java.util.*;
import static net.astropenguin.loopdeck.PlayerController.Action.*;
public final class MainActivity extends Activity {
private static final int PICK_FOLDER=10, BT_PERMISSION=11;
private static final int TEXT=Color.rgb(232,240,235), MUTED=Color.rgb(159,182,168);
private final Handler main=new Handler(Looper.getMainLooper());
private PlayerController controller;
private PlayerController.Browser browser=PlayerController.Browser.empty();
private Bundle restoredBrowser;
private Uri pendingFolder;
private boolean bound,dragging,resumed;
private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
private Button choose, bluetoothButton, pageBack, pageNext, find;
private LinearLayout remotePages;
private int pendingMode;
private Button play, setA, setB, loopButton, clear, previous, next, stop;
private SeekBar seek;
private TextView batteryLabel,volumeLabel;
private Button volumeDown,volumeUp;
private EntryAdapter adapter;
private final ServiceConnection connection=new ServiceConnection() {
@Override public void onServiceConnected(ComponentName name, IBinder binder) {
controller=((PlaybackService.LocalBinder)binder).service().controller;
controller.restoreBrowser(restoredBrowser); restoredBrowser=null;
if (pendingFolder!=null) { controller.musicFolder(pendingFolder); pendingFolder=null; }
render();
}
@Override public void onServiceDisconnected(ComponentName name) { controller=null; }
};
private final Runnable tick=new Runnable() {
@Override public void run() { if (resumed) { render(); main.postDelayed(this,250); } }
};
@Override public void onCreate(Bundle saved) {
super.onCreate(saved);
buildUi();
bound=bindService(new Intent(this,PlaybackService.class),connection,BIND_AUTO_CREATE);
restoredBrowser=saved;
if (saved!=null && saved.getString("pendingFolder")!=null) pendingFolder=Uri.parse(saved.getString("pendingFolder"));
}
private int dp(int value) { return Math.round(getResources().getDisplayMetrics().density*value); }
private TextView text(String content, int sp, int color) {
TextView view=new TextView(this); view.setText(content); view.setTextSize(sp); view.setTextColor(color); return view;
}
private LinearLayout row() {
LinearLayout view=new LinearLayout(this); view.setOrientation(LinearLayout.HORIZONTAL); view.setGravity(Gravity.CENTER_VERTICAL); return view;
}
private Button button(LinearLayout parent, String label, View.OnClickListener listener) {
Button view=new Button(this); view.setText(label); view.setAllCaps(false); view.setTextSize(14);
view.setMinWidth(0); view.setMinimumWidth(0); view.setPadding(dp(4),0,dp(4),0);
view.setOnClickListener(listener); parent.addView(view,new LinearLayout.LayoutParams(0,dp(48),1)); return view;
}
private void buildUi() {
LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(dp(12),dp(6),dp(12),dp(6)); setContentView(root);
LinearLayout top=row();
TextView brand=text("LoopDeck",22,TEXT); top.addView(brand,new LinearLayout.LayoutParams(0,dp(44),1));
choose=new Button(this); choose.setText("Music folder"); choose.setAllCaps(false); choose.setOnClickListener(v -> { if (controller!=null) controller.chooseFolder(this::pickFolder); });
top.addView(choose); root.addView(top);
LinearLayout bluetoothRow=row();
bluetoothButton=button(bluetoothRow,"Bluetooth: off",v -> bluetoothMenu());
root.addView(bluetoothRow);
batteryLabel=text("",12,MUTED); batteryLabel.setVisibility(View.GONE); root.addView(batteryLabel);
remotePages=row();
pageBack=button(remotePages,"‹ Page",v -> { if (controller!=null) controller.page(-1); });
find=button(remotePages,"Find / refresh",v -> remoteSearch());
pageNext=button(remotePages,"Page ›",v -> { if (controller!=null) controller.page(1); });
remotePages.setVisibility(View.GONE); root.addView(remotePages);
LinearLayout navigation=row();
Button up=new Button(this); up.setText("↑"); up.setContentDescription("Parent folder"); up.setOnClickListener(v -> goUp());
navigation.addView(up,new LinearLayout.LayoutParams(dp(48),dp(44)));
folderLabel=text("Select a folder on internal storage or microSD",12,MUTED); folderLabel.setMaxLines(2);
navigation.addView(folderLabel,new LinearLayout.LayoutParams(0,dp(44),1)); root.addView(navigation);
ListView list=new ListView(this); adapter=new EntryAdapter(); list.setAdapter(adapter);
list.setOnItemClickListener((parent,view,position,id) -> { if (controller!=null) controller.open(browser,position); });
root.addView(list,new LinearLayout.LayoutParams(-1,0,1));
View divider=new View(this); divider.setBackgroundColor(Color.rgb(54,75,63)); root.addView(divider,new LinearLayout.LayoutParams(-1,dp(1)));
trackTitle=text("Choose a track",18,TEXT); trackTitle.setMaxLines(2); trackTitle.setPadding(0,dp(6),0,0); root.addView(trackTitle);
trackFormat=text("FFmpeg audio player",11,MUTED); trackFormat.setMaxLines(2); root.addView(trackFormat);
times=text("0:00.000 / 0:00.000",13,TEXT); root.addView(times);
seek=new SeekBar(this); seek.setMax(10000); root.addView(seek,new LinearLayout.LayoutParams(-1,dp(36)));
seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override public void onStartTrackingTouch(SeekBar bar) { dragging=true; }
@Override public void onProgressChanged(SeekBar bar,int value,boolean user) {
if (user && controller!=null) times.setText(time(controller.snapshot().duration*value/10000)+" / "+time(controller.snapshot().duration));
}
@Override public void onStopTrackingTouch(SeekBar bar) {
if (controller!=null) controller.seek(controller.snapshot().duration*bar.getProgress()/10000); dragging=false;
}
});
LinearLayout transport=row();
previous=button(transport,"Previous",v -> control(PREVIOUS));
play=button(transport,"Play",v -> control(TOGGLE));
next=button(transport,"Next",v -> control(NEXT));
stop=button(transport,"Stop",v -> control(STOP)); root.addView(transport);
LinearLayout volume=row();
volumeDown=button(volume,"Volume −",v -> control(VOLUME_DOWN));
volumeDown.setContentDescription("Decrease player volume");
volumeLabel=text("Volume —",12,TEXT); volumeLabel.setGravity(Gravity.CENTER);
volume.addView(volumeLabel,new LinearLayout.LayoutParams(0,dp(48),1));
volumeUp=button(volume,"Volume +",v -> control(VOLUME_UP));
volumeUp.setContentDescription("Increase player volume"); root.addView(volume);
LinearLayout markers=row();
setA=button(markers,"Set A",v -> control(MARK_A));
setB=button(markers,"Set B",v -> control(MARK_B));
loopButton=button(markers,"Loop off",v -> control(LOOP));
clear=button(markers,"Clear",v -> control(CLEAR)); root.addView(markers);
setA.setOnLongClickListener(v -> { editMarkers(); return true; });
setB.setOnLongClickListener(v -> { editMarkers(); return true; });
loopTimes=text("A — B — · Hold Set A/B to edit times",11,MUTED); root.addView(loopTimes);
status=text("Ready",12,MUTED); status.setMaxLines(3); status.setTextIsSelectable(true); root.addView(status);
}
private void pickFolder() {
Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
startActivityForResult(intent,PICK_FOLDER);
}
@Override protected void onActivityResult(int request,int result,Intent data) {
super.onActivityResult(request,result,data);
if (request!=PICK_FOLDER || result!=RESULT_OK || data==null || data.getData()==null) return;
Uri chosen=data.getData();
try {
getContentResolver().takePersistableUriPermission(chosen,data.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION);
if (controller!=null) controller.musicFolder(chosen); else pendingFolder=chosen;
} catch (Exception e) { showError("Folder permission failed: "+e.getMessage()); }
}
private void control(PlayerController.Action action) { if (controller!=null) controller.perform(action); }
private boolean goUp() { return controller!=null && controller.up(); }
@Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
private void render() {
if (controller==null) return;
PlayerController.Browser current=controller.browser();
if (browser!=current) { browser=current; adapter.notifyDataSetChanged(); }
remotePages.setVisibility(browser.paged ? View.VISIBLE : View.GONE);
replace(choose,browser.rootLabel); choose.setEnabled(browser.ready);
replace(folderLabel,browser.title);
replace(bluetoothButton,controller.bluetoothStatus());
pageBack.setEnabled(browser.previous); pageNext.setEnabled(browser.next); find.setEnabled(browser.ready);
PlaybackService.Snapshot s=controller.snapshot();
replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,controller.status());
replace(play,s.playing ? "Pause" : "Play"); replace(loopButton,s.loop ? "Loop on" : "Loop off");
if (!dragging) {
replace(times,time(s.position)+" / "+time(s.duration));
int value=s.duration>0 ? (int)Math.min(10000,s.position*10000/s.duration) : 0;
if (seek.getProgress()!=value) seek.setProgress(value);
}
boolean available=controller.available();
DeviceStatus device=controller.deviceStatus();
batteryLabel.setVisibility(browser.paged ? View.VISIBLE : View.GONE);
replace(batteryLabel,!available ? "Server battery · disconnected" : "Server battery · "+
(device.battery<0 ? "unavailable" : device.battery+"%")+(device.charging ? " · charging" : ""));
replace(volumeLabel,device.volume<0 ? "Volume —" : "Volume "+device.volume+" / "+device.maxVolume);
volumeDown.setEnabled(available && device.volume>0);
volumeUp.setEnabled(available && device.volume>=0 && device.volume<device.maxVolume);
previous.setEnabled(available); next.setEnabled(available);
seek.setEnabled(available && s.loaded && s.duration>0);
play.setEnabled(available && s.loaded); stop.setEnabled(available && s.loaded);
setA.setEnabled(available && s.loaded); setB.setEnabled(available && s.loaded); loopButton.setEnabled(available && s.loaded); clear.setEnabled(available && s.loaded);
replace(loopTimes,"A "+(s.a<0 ? "—" : time(s.a))+" B "+(s.b<0 ? "—" : time(s.b))+" · Hold to edit");
}
private static void replace(TextView view,String value) { if (!view.getText().toString().equals(value)) view.setText(value); }
private static String time(long ms) {
ms=Math.max(ms,0);
return String.format(Locale.ROOT,"%d:%02d.%03d",ms/60000,ms/1000%60,ms%1000);
}
private void editMarkers() {
if (controller==null || !controller.snapshot().loaded) return;
PlaybackService.Snapshot s=controller.snapshot();
LinearLayout form=new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); form.setPadding(dp(20),0,dp(20),0);
form.addView(text("Times in milliseconds. Leave a field blank to unset it.",14,MUTED));
EditText inputA=new EditText(this), inputB=new EditText(this);
inputA.setHint("A (ms)"); inputB.setHint("B (ms)");
inputA.setInputType(InputType.TYPE_CLASS_NUMBER); inputB.setInputType(InputType.TYPE_CLASS_NUMBER);
if (s.a>=0) inputA.setText(Long.toString(s.a)); if (s.b>=0) inputB.setText(Long.toString(s.b));
form.addView(inputA); form.addView(inputB);
AlertDialog dialog=new AlertDialog.Builder(this).setTitle("Edit A–B loop").setView(form)
.setNegativeButton("Cancel",null).setPositiveButton("Apply",null).create();
dialog.setOnShowListener(v -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(click -> {
try {
long newA=inputA.getText().length()==0 ? -1 : Long.parseLong(inputA.getText().toString());
long newB=inputB.getText().length()==0 ? -1 : Long.parseLong(inputB.getText().toString());
if (newA>Long.MAX_VALUE/384000 || newB>Long.MAX_VALUE/384000) throw new NumberFormatException();
if (newA>=0 && newB>=0 && newB<=newA) { inputB.setError("B must be after A"); return; }
if (controller!=null) controller.markers(newA,newB); dialog.dismiss();
} catch (NumberFormatException e) { inputA.setError("Enter valid milliseconds"); }
})); dialog.show();
}
private void bluetoothMenu() {
if (controller==null) return;
new AlertDialog.Builder(this).setTitle("Bluetooth mode")
.setItems(new String[]{"Local player / Bluetooth off","Server · play music here","Client · control a paired player"},(dialog,which) -> {
if (which==0) { controller.stopBluetooth(); render(); }
else requestBluetooth(which);
}).setNegativeButton("Cancel",null).show();
}
private void requestBluetooth(int mode) {
pendingMode=mode;
if (Build.VERSION.SDK_INT>=31 && checkSelfPermission("android.permission.BLUETOOTH_CONNECT")!=PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{"android.permission.BLUETOOTH_CONNECT"},BT_PERMISSION); return;
}
configureBluetooth(mode);
}
@Override public void onRequestPermissionsResult(int request,String[] permissions,int[] grants) {
super.onRequestPermissionsResult(request,permissions,grants);
if (request==BT_PERMISSION) {
if (grants.length>0 && grants[0]==PackageManager.PERMISSION_GRANTED) configureBluetooth(pendingMode);
else showError("Nearby devices permission is required for Bluetooth control.");
}
}
private void configureBluetooth(int mode) {
if (controller==null) return;
try {
BluetoothManager manager=(BluetoothManager)getSystemService(BLUETOOTH_SERVICE);
BluetoothAdapter bluetooth=manager==null ? null : manager.getAdapter();
if (bluetooth==null || !bluetooth.isEnabled()) { showError("Enable Bluetooth in Android Settings first."); return; }
if (mode==BluetoothLink.SERVER) {
if (!controller.hasMusicFolder()) { showError("Choose a Music folder on this player first."); return; }
controller.startBluetooth(mode,null); render(); return;
}
ArrayList<BluetoothDevice> devices=new ArrayList<>(bluetooth.getBondedDevices());
devices.sort((a,b) -> deviceLabel(a).compareToIgnoreCase(deviceLabel(b)));
if (devices.isEmpty()) {
new AlertDialog.Builder(this).setTitle("Paired player · start Server there first")
.setMessage("Pair the phone and player in Android Bluetooth Settings first, then return here.")
.setPositiveButton("OK",null).show(); return;
}
String[] labels=new String[devices.size()];
for (int i=0;i<labels.length;i++) labels[i]=deviceLabel(devices.get(i));
new AlertDialog.Builder(this).setTitle("Select a paired device").setItems(labels,(dialog,index) -> {
controller.startBluetooth(BluetoothLink.CLIENT,devices.get(index).getAddress()); render();
}).setNegativeButton("Cancel",null).show();
} catch (Exception e) { showError("Bluetooth: "+e.getMessage()); }
}
private static String deviceLabel(BluetoothDevice device) {
String name=device.getName(); return (name==null ? "Bluetooth device" : name)+" · "+device.getAddress();
}
private void remoteSearch() {
if (controller==null) return;
EditText query=new EditText(this); query.setSingleLine(true); query.setHint("Filename in this folder (blank = all)");
new AlertDialog.Builder(this).setTitle("Find in remote folder").setView(query)
.setPositiveButton("Find",(dialog,which) -> { if (controller!=null) controller.filter(query.getText().toString()); })
.setNeutralButton("Refresh root",(dialog,which) -> { if (controller!=null) controller.refresh(); })
.setNegativeButton("Cancel",null).show();
}
private void showError(String message) { new AlertDialog.Builder(this).setTitle("LoopDeck").setMessage(message).setPositiveButton("OK",null).show(); }
@Override protected void onResume() { super.onResume(); resumed=true; main.post(tick); }
@Override protected void onPause() { resumed=false; main.removeCallbacks(tick); super.onPause(); }
@Override protected void onSaveInstanceState(Bundle out) {
super.onSaveInstanceState(out);
if (controller!=null) controller.saveBrowser(out);
else if (restoredBrowser!=null) out.putAll(restoredBrowser);
if (pendingFolder!=null) out.putString("pendingFolder",pendingFolder.toString());
}
@Override protected void onDestroy() {
main.removeCallbacks(tick);
if (bound) unbindService(connection); super.onDestroy();
}
private final class EntryAdapter extends BaseAdapter {
@Override public int getCount() { return browser.items.size(); }
@Override public Object getItem(int position) { return browser.items.get(position); }
@Override public long getItemId(int position) { return position; }
@Override public View getView(int position,View reusable,ViewGroup parent) {
TextView view=reusable instanceof TextView ? (TextView)reusable : text("",15,TEXT);
view.setPadding(dp(10),dp(12),dp(6),dp(12)); view.setMinHeight(dp(50)); view.setMaxLines(2);
PlayerController.Item item=browser.items.get(position); boolean folder=item.folder; String name=item.name;
view.setText((folder ? "▸ " : "")+name);
view.setTextColor(folder ? Color.rgb(115,218,193) : TEXT); return view;
}
}
}