penguin/loopdeck

A simple player that support A-B looping for FiiO M11S

app/src/main/java/net/astropenguin/loopdeck/PlayerController.java

raw · 12531 bytes

package net.astropenguin.loopdeck;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.DocumentsContract;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import net.astropenguin.loopdeck.MusicLibrary.Entry;

/** The UI/media control boundary. PlaybackService's command methods are always local. */
final class PlayerController implements AutoCloseable {
    enum Action {
        TOGGLE("toggle"), RESUME("resume"), PAUSE("pause"), STOP("stop"), NEXT("next"), PREVIOUS("previous"),
        SEEK("seek"), MARK_A("a"), MARK_B("b"), MARKERS("markers"), LOOP("loop"), CLEAR("clear");
        final String wire;
        Action(String wire) { this.wire=wire; }
    }
    static final class Item {
        final String name; final boolean folder;
        private final Entry local;
        private final BluetoothLink.Item remote;
        Item(Entry entry) { name=entry.name; folder=entry.folder; local=entry; remote=null; }
        Item(BluetoothLink.Item entry) { name=entry.name; folder=entry.folder; local=null; remote=entry; }
    }
    /** Immutable rows and capabilities; the activity renders this without knowing the endpoint. */
    static final class Browser {
        final String title,rootLabel;
        final List<Item> items;
        final boolean paged,up,previous,next,ready;
        private final BluetoothLink.Listing listing;
        Browser(String title,List<Item> items,boolean up) {
            this.title=title; this.items=Collections.unmodifiableList(new ArrayList<>(items)); this.up=up;
            rootLabel="Music folder"; paged=previous=next=false; ready=true; listing=null;
        }
        Browser(BluetoothLink.Listing listing,boolean ready) {
            this.listing=listing; this.ready=ready; paged=true; rootLabel="Remote root"; up=ready && listing.up;
            previous=ready && listing.offset>0; next=ready && listing.offset+listing.items.size()<listing.total;
            title=listing.title+" · "+(listing.total==0 ? "0 items" : (listing.offset+1)+"–"+(listing.offset+listing.items.size())+" / "+listing.total);
            ArrayList<Item> rows=new ArrayList<>(); for (BluetoothLink.Item item:listing.items) rows.add(new Item(item));
            items=Collections.unmodifiableList(rows);
        }
        static Browser empty() { return new Browser("Select a folder on internal storage or microSD",Collections.emptyList(),false); }
    }
    private final PlaybackService player;
    private final BluetoothLink bluetooth;
    private final Handler main=new Handler(Looper.getMainLooper());
    private final ExecutorService files=Executors.newSingleThreadExecutor();
    private final AtomicInteger generation=new AtomicInteger();
    private final ArrayList<String> parents=new ArrayList<>();
    private Uri tree;
    private String directory;
    private boolean trackView;
    private volatile boolean closed;
    private volatile Browser localBrowser=Browser.empty();
    private volatile String browserError="";
    private Browser remoteBrowser;

    PlayerController(PlaybackService player) {
        this.player=player; bluetooth=new BluetoothLink(player);
        String saved=player.getSharedPreferences("MainActivity",Context.MODE_PRIVATE).getString("tree",null);
        if (saved!=null) { tree=Uri.parse(saved); directory=DocumentsContract.getTreeDocumentId(tree); listFolder(); }
    }
    private boolean remote() { return bluetooth.mode==BluetoothLink.CLIENT; }
    boolean bluetoothEnabled() { return bluetooth.mode!=BluetoothLink.LOCAL; }
    boolean available() { return !remote() || bluetooth.connected; }
    boolean hasMusicFolder() { return tree!=null; }
    PlaybackService.Snapshot snapshot() { return remote() ? bluetooth.remoteSnapshot() : player.snapshot; }
    String bluetoothStatus() { return bluetooth.status; }
    String status() {
        String error=!bluetooth.error.isEmpty() ? "Bluetooth: "+bluetooth.error : remote() ? "" : browserError;
        return error.isEmpty() ? snapshot().status : error+"\n"+snapshot().status;
    }
    Browser browser() {
        if (!remote()) return localBrowser;
        BluetoothLink.Listing listing=bluetooth.listing;
        if (remoteBrowser==null || remoteBrowser.listing!=listing || remoteBrowser.ready!=bluetooth.connected)
            remoteBrowser=new Browser(listing,bluetooth.connected);
        return remoteBrowser;
    }
    void perform(Action action) { execute(action,0,0); }
    void seek(long position) { execute(Action.SEEK,position,0); }
    void markers(long a,long b) { execute(Action.MARKERS,a,b); }
    private void execute(Action action,long first,long second) {
        if (closed) return;
        if (remote()) {
            switch (action) {
                case SEEK: bluetooth.command(action.wire,"position",first); break;
                case MARKERS: bluetooth.command(action.wire,"a",first,"b",second); break;
                default: bluetooth.command(action.wire);
            }
            return;
        }
        switch (action) {
            case TOGGLE: foreground(); player.toggle(); break;
            case RESUME: foreground(); player.resumePlayback(); break;
            case PAUSE: player.pausePlayback(); break;
            case STOP: player.stopPlayback(); break;
            case NEXT: foreground(); player.next(1); break;
            case PREVIOUS: foreground(); player.next(-1); break;
            case SEEK: player.seek(first); break;
            case MARK_A: player.markA(); break;
            case MARK_B: player.markB(); break;
            case MARKERS: player.markers(first,second); break;
            case LOOP: player.toggleLoop(); break;
            case CLEAR: player.clearLoop(); break;
        }
    }
    private void foreground() { player.startForegroundService(new Intent(player,PlaybackService.class)); }
    void startBluetooth(int mode,String address) {
        boolean wasRemote=remote();
        if (mode==BluetoothLink.CLIENT) { generation.incrementAndGet(); player.pausePlayback(); }
        foreground(); bluetooth.start(mode,address); remoteBrowser=null;
        if (!remote() && (wasRemote || mode==BluetoothLink.CLIENT)) listFolder();
        player.bluetoothChanged();
    }
    void stopBluetooth() {
        boolean wasRemote=remote();
        if (wasRemote) generation.incrementAndGet();
        bluetooth.close(); remoteBrowser=null;
        if (wasRemote) listFolder();
        player.bluetoothStopped();
    }
    /** Only the activity owns the Android folder picker; destination selection stays here. */
    void chooseFolder(Runnable picker) {
        if (remote()) bluetooth.command("root"); else picker.run();
    }
    void musicFolder(Uri chosen) {
        tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
        player.getSharedPreferences("MainActivity",Context.MODE_PRIVATE).edit().putString("tree",tree.toString()).apply();
        listFolder();
    }
    void page(int direction) {
        if (!remote() || !bluetooth.connected) return;
        bluetooth.page(bluetooth.listing.offset+direction*BluetoothLink.PAGE_SIZE);
    }
    void filter(String query) { if (remote()) bluetooth.command("filter","query",query); }
    void refresh() {
        if (remote()) bluetooth.command("root"); else listFolder();
    }
    boolean up() {
        if (remote()) {
            if (!browser().up) return false;
            bluetooth.command("up"); return true;
        }
        if (trackView) { listFolder(); return true; }
        if (parents.isEmpty()) return false;
        directory=parents.remove(parents.size()-1); listFolder(); return true;
    }
    void open(Browser view,int position) {
        if (closed || position<0 || position>=view.items.size()) return;
        Item item=view.items.get(position);
        if (remote()) {
            if (view.listing!=null && bluetooth.connected) bluetooth.open(item.remote,view.listing);
            return;
        }
        if (view!=localBrowser || item.local==null) return;
        Entry entry=item.local;
        if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); }
        else if (entry.track!=null) playEntry(entry,view.items);
        else openTrackView(entry,view.items);
    }
    private void playEntry(Entry entry,List<Item> items) {
        ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
        for (Item item:items) {
            Entry candidate=item.local;
            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));
            }
        }
        foreground(); player.play(tracks,selected);
    }
    private boolean current(int request) { return !closed && request==generation.get() && !remote(); }
    private void listFolder() {
        if (closed || remote()) return;
        if (tree==null) { localBrowser=Browser.empty(); return; }
        final Uri selectedTree=tree; final String selectedDirectory=directory;
        trackView=false; browserError="";
        final int request=generation.incrementAndGet();
        localBrowser=new Browser("Loading folder…",Collections.emptyList(),!parents.isEmpty());
        files.submit(() -> {
            ArrayList<Item> rows=new ArrayList<>(); String error="";
            try { for (Entry entry:MusicLibrary.list(player,selectedTree,selectedDirectory)) rows.add(new Item(entry)); }
            catch (Exception e) { error="Cannot read folder: "+e.getMessage()+". Choose the folder again if the card changed."; }
            final String failure=error;
            main.post(() -> {
                if (!current(request)) return;
                browserError=failure; localBrowser=new Browser(selectedDirectory+" · "+rows.size()+" items",rows,!parents.isEmpty());
            });
        });
    }
    private void openTrackView(Entry entry,List<Item> items) {
        ArrayList<Entry> folder=new ArrayList<>(); for (Item item:items) folder.add(item.local);
        final int request=generation.incrementAndGet(); browserError="";
        localBrowser=new Browser("Reading track information…",items,!parents.isEmpty());
        files.submit(() -> {
            ArrayList<Track> found=new ArrayList<>(); String error=null;
            try { found=MusicLibrary.tracks(player,entry,folder); }
            catch (Exception | LinkageError e) { error=e.getMessage(); }
            final ArrayList<Track> tracks=found; final String failure=error;
            main.post(() -> {
                if (!current(request)) return;
                if (!tracks.isEmpty()) {
                    trackView=true; ArrayList<Item> rows=new ArrayList<>();
                    for (Track track:tracks) rows.add(new Item(new Entry(track)));
                    localBrowser=new Browser(entry.name+" · "+tracks.size()+" tracks",rows,true);
                } else {
                    localBrowser=new Browser(directory+" · "+items.size()+" items",items,!parents.isEmpty());
                    if (MusicLibrary.isCue(entry)) browserError=failure==null ? "No track information found" : failure;
                    else playEntry(entry,items);
                }
            });
        });
    }
    void saveBrowser(Bundle out) {
        if (tree!=null) { out.putString("tree",tree.toString()); out.putString("directory",directory); out.putStringArrayList("parents",new ArrayList<>(parents)); }
    }
    void restoreBrowser(Bundle saved) {
        if (saved==null || saved.getString("tree")==null) return;
        Uri savedTree=Uri.parse(saved.getString("tree"));
        String savedDirectory=saved.getString("directory",DocumentsContract.getTreeDocumentId(savedTree));
        ArrayList<String> savedParents=saved.getStringArrayList("parents");
        if (savedParents==null) savedParents=new ArrayList<>();
        if (tree!=null && tree.toString().equals(savedTree.toString()) && Objects.equals(directory,savedDirectory) && parents.equals(savedParents)) return;
        tree=savedTree; directory=savedDirectory; parents.clear(); parents.addAll(savedParents); listFolder();
    }
    @Override public void close() {
        closed=true; generation.incrementAndGet(); files.shutdownNow(); bluetooth.close();
    }
}