app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
raw · 22430 bytes
package net.astropenguin.loopdeck;
import android.app.*;
import android.content.*;
import android.database.Cursor;
import android.graphics.Color;
import android.net.Uri;
import android.os.*;
import android.provider.DocumentsContract;
import android.text.InputType;
import android.view.*;
import android.widget.*;
import java.util.*;
import java.io.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public final class MainActivity extends Activity {
private static final int PICK_FOLDER=10;
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 final ExecutorService files=Executors.newSingleThreadExecutor();
private final AtomicInteger generation=new AtomicInteger();
private final ArrayList<Entry> entries=new ArrayList<>();
private final ArrayList<String> parents=new ArrayList<>();
private PlaybackService player;
private boolean bound, dragging, resumed;
private boolean trackView;
private Uri tree;
private String directory;
private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
private Button play, setA, setB, loopButton, clear, previous, next, stop;
private SeekBar seek;
private EntryAdapter adapter;
private final ServiceConnection connection=new ServiceConnection() {
@Override public void onServiceConnected(ComponentName name, IBinder binder) {
player=((PlaybackService.LocalBinder)binder).service(); render();
}
@Override public void onServiceDisconnected(ComponentName name) { player=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);
String stored=getPreferences(MODE_PRIVATE).getString("tree",null);
if (saved!=null) stored=saved.getString("tree",stored);
if (stored!=null) {
tree=Uri.parse(stored);
directory=saved==null ? DocumentsContract.getTreeDocumentId(tree) : saved.getString("directory",DocumentsContract.getTreeDocumentId(tree));
if (saved!=null && saved.getStringArrayList("parents")!=null) parents.addAll(saved.getStringArrayList("parents"));
listFolder();
}
}
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));
Button choose=new Button(this); choose.setText("Music folder"); choose.setAllCaps(false); choose.setOnClickListener(v -> pickFolder());
top.addView(choose); root.addView(top);
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) -> select(entries.get(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 && player!=null) times.setText(time(player.snapshot.duration*value/10000)+" / "+time(player.snapshot.duration));
}
@Override public void onStopTrackingTouch(SeekBar bar) {
if (player!=null) player.seek(player.snapshot.duration*bar.getProgress()/10000); dragging=false;
}
});
LinearLayout transport=row();
previous=button(transport,"Previous",v -> { if (player!=null) { ensureForeground(); player.next(-1); } });
play=button(transport,"Play",v -> { if (player!=null) { ensureForeground(); player.toggle(); } });
next=button(transport,"Next",v -> { if (player!=null) { ensureForeground(); player.next(1); } });
stop=button(transport,"Stop",v -> { if (player!=null) player.stopPlayback(); }); root.addView(transport);
LinearLayout markers=row();
setA=button(markers,"Set A",v -> { if (player!=null) player.markA(); });
setB=button(markers,"Set B",v -> { if (player!=null) player.markB(); });
loopButton=button(markers,"Loop off",v -> { if (player!=null) player.toggleLoop(); });
clear=button(markers,"Clear",v -> { if (player!=null) player.clearLoop(); }); 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);
tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
getPreferences(MODE_PRIVATE).edit().putString("tree",tree.toString()).apply(); listFolder();
} catch (Exception e) { showError("Folder permission failed: "+e.getMessage()); }
}
private void listFolder() {
if (tree==null) return;
final Uri selectedTree=tree;
final String selectedDirectory=directory;
trackView=false;
final int request=generation.incrementAndGet();
entries.clear(); adapter.notifyDataSetChanged(); folderLabel.setText("Loading folder…");
files.submit(() -> {
ArrayList<Entry> result=new ArrayList<>();
String error=null;
try {
Uri children=DocumentsContract.buildChildDocumentsUriUsingTree(selectedTree,selectedDirectory);
String[] columns={DocumentsContract.Document.COLUMN_DOCUMENT_ID,DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE};
try (Cursor c=getContentResolver().query(children,columns,null,null,null)) {
if (c==null) throw new IllegalStateException("Storage provider returned no folder listing");
while (c.moveToNext()) {
if (request!=generation.get()) return;
String id=c.getString(0), name=c.getString(1), mime=c.getString(2);
result.add(new Entry(id,name==null ? id : name,mime,DocumentsContract.buildDocumentUriUsingTree(selectedTree,id)));
}
}
result.sort((x,y) -> x.folder!=y.folder ? (x.folder ? -1 : 1) : x.name.compareToIgnoreCase(y.name));
} catch (Exception e) { error="Cannot read folder: "+e.getMessage()+". Choose the folder again if the card changed."; }
final String failure=error;
main.post(() -> {
if (isDestroyed() || request!=generation.get()) return;
entries.addAll(result); adapter.notifyDataSetChanged();
folderLabel.setText(selectedDirectory+" · "+result.size()+" items");
if (failure!=null) showError(failure);
});
});
}
private void select(Entry entry) {
if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); return; }
if (player==null) { showError("Player is still starting"); return; }
if (entry.track==null) { openTrackView(entry); return; }
playEntry(entry);
}
private void playEntry(Entry entry) {
ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
for (Entry candidate:entries) {
if (!candidate.folder && (candidate==entry || candidate.audio)) {
if (candidate==entry) selected=tracks.size();
tracks.add(candidate.track!=null ? candidate.track : new Track(candidate.uri,candidate.name));
}
}
ensureForeground(); player.play(tracks,selected);
}
private static boolean isCue(Entry e) { return e.name.toLowerCase(Locale.ROOT).endsWith(".cue"); }
private String readCue(Entry e) throws IOException {
try (InputStream in=getContentResolver().openInputStream(e.uri)) {
if (in==null) throw new IOException("Cannot open CUE sheet");
ByteArrayOutputStream out=new ByteArrayOutputStream(); byte[] buffer=new byte[8192]; int n;
while ((n=in.read(buffer))!=-1) {
if (out.size()+n>2*1024*1024) throw new IOException("CUE sheet is too large");
out.write(buffer,0,n);
}
byte[] bytes=out.toByteArray();
if (bytes.length>=2 && ((bytes[0]==(byte)0xff && bytes[1]==(byte)0xfe) ||
(bytes[0]==(byte)0xfe && bytes[1]==(byte)0xff))) return new String(bytes,StandardCharsets.UTF_16);
try { return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
catch (CharacterCodingException invalidUtf8) { return new String(bytes,Charset.forName("windows-31j")); }
}
}
private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image) {
return cueTracks(text,folder,image,false);
}
private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image,boolean embedded) {
ArrayList<Track> result=new ArrayList<>();
for (CueSheet.Part part:CueSheet.parse(text)) {
Entry target=null;
String name=part.file.replace('\\','/');
while (name.startsWith("./")) name=name.substring(2);
if (image!=null && (embedded || name.isEmpty() || name.equalsIgnoreCase(image.name))) target=image;
if (image==null) for (Entry candidate:folder)
if (candidate.audio && candidate.name.equalsIgnoreCase(name)) { target=candidate; break; }
if (target!=null) result.add(new Track(target.uri,part.number+". "+part.title,part.start,part.end));
else if (image==null) return new ArrayList<>(); // Never play a partial/misresolved sheet.
}
return result;
}
private void openTrackView(Entry entry) {
final ArrayList<Entry> folder=new ArrayList<>(entries);
final int request=generation.incrementAndGet();
folderLabel.setText("Reading track information…");
files.submit(() -> {
ArrayList<Track> found=new ArrayList<>(); String error=null;
try {
if (isCue(entry)) {
found=cueTracks(readCue(entry),folder,null);
if (found.isEmpty()) throw new IOException("No usable audio tracks. CUE FILE names must match audio files in this folder.");
} else {
// Prefer a same-named sheet, then sheets that explicitly reference this image.
String stem=entry.name.replaceFirst("\\.[^.]+$","");
folder.sort((x,y) -> Boolean.compare(!x.name.equalsIgnoreCase(stem+".cue"),!y.name.equalsIgnoreCase(stem+".cue")));
for (Entry candidate:folder) if (isCue(candidate)) {
try { found=cueTracks(readCue(candidate),folder,entry); } catch (IOException ignored) { continue; }
if (!found.isEmpty()) break;
}
if (found.isEmpty()) {
long handle=0;
try (ParcelFileDescriptor file=getContentResolver().openFileDescriptor(entry.uri,"r")) {
if (file==null) throw new IOException("Cannot open selected file");
handle=NativeDecoder.open(file.getFd());
found=cueTracks(NativeDecoder.cueSheet(handle),folder,entry,true);
if (found.isEmpty()) {
String[] chapters=NativeDecoder.chapters(handle);
long total=NativeDecoder.duration(handle), previousEnd=-1;
for (int i=0;i<chapters.length;i+=3) {
long start=Long.parseLong(chapters[i]), end=Long.parseLong(chapters[i+1]);
if (end<=start && i+3<chapters.length) end=Long.parseLong(chapters[i+3]);
if (end<=start) end=total;
if (start<0 || start<previousEnd || end<=start || (total>0 && end>total)) { found.clear(); break; }
found.add(new Track(entry.uri,(i/3+1)+". "+chapters[i+2],start,end)); previousEnd=end;
}
}
} finally { if (handle!=0) NativeDecoder.close(handle); }
}
}
} catch (Exception | LinkageError e) { error=e.getMessage(); }
final ArrayList<Track> tracks=found; final String failure=error;
main.post(() -> {
if (isDestroyed() || request!=generation.get()) return;
if (!tracks.isEmpty()) {
trackView=true; entries.clear();
for (Track track:tracks) entries.add(new Entry(track));
adapter.notifyDataSetChanged(); folderLabel.setText(entry.name+" · "+tracks.size()+" tracks");
} else {
folderLabel.setText(directory+" · "+entries.size()+" items");
if (isCue(entry)) showError(failure==null ? "No track information found" : failure);
else playEntry(entry);
}
});
});
}
private void ensureForeground() { startForegroundService(new Intent(this,PlaybackService.class)); }
private boolean goUp() {
if (trackView) { listFolder(); return true; }
if (parents.isEmpty()) return false;
directory=parents.remove(parents.size()-1); listFolder(); return true;
}
@Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
private void render() {
if (player==null) return;
PlaybackService.Snapshot s=player.snapshot;
replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,s.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);
}
seek.setEnabled(s.loaded && s.duration>0);
play.setEnabled(s.loaded); stop.setEnabled(s.loaded);
setA.setEnabled(s.loaded); setB.setEnabled(s.loaded); loopButton.setEnabled(s.loaded); clear.setEnabled(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 (player==null || !player.snapshot.loaded) return;
PlaybackService.Snapshot s=player.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 (player!=null) player.markers(newA,newB); dialog.dismiss();
} catch (NumberFormatException e) { inputA.setError("Enter valid milliseconds"); }
})); dialog.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 (tree!=null) { out.putString("tree",tree.toString()); out.putString("directory",directory); out.putStringArrayList("parents",parents); }
}
@Override protected void onDestroy() {
generation.incrementAndGet(); files.shutdownNow(); main.removeCallbacks(tick);
if (bound) unbindService(connection); super.onDestroy();
}
private static final class Entry {
final String id,name; final Uri uri; final boolean folder,audio;
final Track track;
Entry(Track track) {
this.track=track; id=track.uri.toString(); name=track.name; uri=track.uri; folder=false; audio=true;
}
Entry(String id,String name,String mime,Uri uri) {
this.id=id; this.name=name; this.uri=uri; track=null;
folder=DocumentsContract.Document.MIME_TYPE_DIR.equals(mime);
audio=(mime!=null && mime.startsWith("audio/")) || name.toLowerCase(Locale.ROOT).matches(
".*\\.(mp3|mp2|mp1|flac|ape|tak|tta|wv|wavpack|wma|wav|wave|aif|aiff|aifc|m4a|mp4|aac|ogg|oga|opus|mpc|mpp|shn|dsf|dff|ac3|dts|amr|au|snd|caf|ra|rm|spx|mka)$");
}
}
private final class EntryAdapter extends BaseAdapter {
@Override public int getCount() { return entries.size(); }
@Override public Object getItem(int position) { return entries.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);
Entry e=entries.get(position); view.setText((e.folder ? "▸ " : "")+e.name);
view.setTextColor(e.folder ? Color.rgb(115,218,193) : TEXT); return view;
}
}
}