mirror of
https://github.com/clangen/musikcube.git
synced 2025-03-15 16:21:17 +00:00
Converted remaining Java code to Kotlin. Not everything is completely
idiomatic yet, but this is a great start.
This commit is contained in:
parent
bfbd4db5e5
commit
e86cec10ea
@ -60,6 +60,7 @@ dependencies {
|
||||
|
||||
compile "android.arch.persistence.room:runtime:1.0.0-alpha3"
|
||||
annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha3"
|
||||
kapt "android.arch.persistence.room:compiler:1.0.0-alpha3"
|
||||
|
||||
compile 'com.neovisionaries:nv-websocket-client:1.31'
|
||||
compile 'com.squareup.okhttp3:okhttp:3.8.0'
|
||||
|
@ -10,7 +10,6 @@ import io.casey.musikcube.remote.util.NetworkUtil
|
||||
import io.fabric.sdk.android.Fabric
|
||||
|
||||
class Application : android.app.Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
instance = this
|
||||
|
||||
|
@ -1,491 +0,0 @@
|
||||
package io.casey.musikcube.remote;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.support.design.widget.Snackbar;
|
||||
import android.support.v4.app.DialogFragment;
|
||||
import android.support.v4.content.ContextCompat;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.playback.PlaybackState;
|
||||
import io.casey.musikcube.remote.playback.RepeatMode;
|
||||
import io.casey.musikcube.remote.ui.activity.AlbumBrowseActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.CategoryBrowseActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.PlayQueueActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.SettingsActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.TrackListActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.WebSocketActivityBase;
|
||||
import io.casey.musikcube.remote.ui.fragment.InvalidPasswordDialogFragment;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.ui.view.MainMetadataView;
|
||||
import io.casey.musikcube.remote.util.Duration;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class MainActivity extends WebSocketActivityBase {
|
||||
private static Map<RepeatMode, Integer> REPEAT_TO_STRING_ID;
|
||||
|
||||
private WebSocketService wss = null;
|
||||
|
||||
private Handler handler = new Handler();
|
||||
private SharedPreferences prefs;
|
||||
private PlaybackService playback;
|
||||
|
||||
private View mainLayout;
|
||||
private MainMetadataView metadataView;
|
||||
private TextView playPause, currentTime, totalTime;
|
||||
private View connectedNotPlayingContainer, disconnectedButton, showOfflineButton;
|
||||
private View disconnectedContainer;
|
||||
private CheckBox shuffleCb, muteCb, repeatCb;
|
||||
private View disconnectedOverlay;
|
||||
private SeekBar seekbar;
|
||||
private int seekbarValue = -1;
|
||||
private int blink = 0;
|
||||
|
||||
static {
|
||||
REPEAT_TO_STRING_ID = new HashMap<>();
|
||||
REPEAT_TO_STRING_ID.put(RepeatMode.None, R.string.button_repeat_off);
|
||||
REPEAT_TO_STRING_ID.put(RepeatMode.List, R.string.button_repeat_list);
|
||||
REPEAT_TO_STRING_ID.put(RepeatMode.Track, R.string.button_repeat_track);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, MainActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.prefs = this.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
this.wss = getWebSocketService();
|
||||
this.playback = getPlaybackService();
|
||||
|
||||
setContentView(R.layout.activity_main);
|
||||
bindEventListeners();
|
||||
|
||||
if (!this.wss.hasValidConnection()) {
|
||||
startActivity(SettingsActivity.getStartIntent(this));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
metadataView.onPause();
|
||||
unbindCheckboxEventListeners();
|
||||
handler.removeCallbacks(updateTimeRunnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
this.playback = getPlaybackService();
|
||||
metadataView.onResume();
|
||||
bindCheckBoxEventListeners();
|
||||
rebindUi();
|
||||
scheduleUpdateTime(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.main_menu, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu menu) {
|
||||
boolean connected = wss.getState() == WebSocketService.State.Connected;
|
||||
boolean streaming = isStreamingSelected();
|
||||
|
||||
menu.findItem(R.id.action_playlists).setEnabled(connected);
|
||||
menu.findItem(R.id.action_genres).setEnabled(connected);
|
||||
|
||||
menu.findItem(R.id.action_remote_toggle).setIcon(
|
||||
streaming ? R.mipmap.ic_toolbar_streaming : R.mipmap.ic_toolbar_remote);
|
||||
|
||||
return super.onPrepareOptionsMenu(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.action_remote_toggle:
|
||||
togglePlaybackService();
|
||||
return true;
|
||||
|
||||
case R.id.action_settings:
|
||||
startActivity(SettingsActivity.getStartIntent(this));
|
||||
return true;
|
||||
|
||||
case R.id.action_genres:
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.GENRE));
|
||||
return true;
|
||||
|
||||
case R.id.action_playlists:
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(
|
||||
this, Messages.Category.PLAYLISTS, CategoryBrowseActivity.DeepLink.TRACKS));
|
||||
return true;
|
||||
|
||||
case R.id.action_offline_tracks:
|
||||
onOfflineTracksSelected();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return this.serviceClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
|
||||
return this.playbackEvents;
|
||||
}
|
||||
|
||||
private void onOfflineTracksSelected() {
|
||||
if (isStreamingSelected()) {
|
||||
startActivity(TrackListActivity.getOfflineStartIntent(this));
|
||||
}
|
||||
else {
|
||||
final String tag = SwitchToOfflineTracksDialog.TAG;
|
||||
if (getSupportFragmentManager().findFragmentByTag(tag) == null) {
|
||||
SwitchToOfflineTracksDialog.newInstance().show(getSupportFragmentManager(), tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onConfirmSwitchToOfflineTracks() {
|
||||
togglePlaybackService();
|
||||
onOfflineTracksSelected();
|
||||
}
|
||||
|
||||
private boolean isStreamingSelected() {
|
||||
return prefs.getBoolean(
|
||||
Prefs.Key.STREAMING_PLAYBACK,
|
||||
Prefs.Default.STREAMING_PLAYBACK);
|
||||
}
|
||||
|
||||
private void togglePlaybackService() {
|
||||
final boolean streaming = isStreamingSelected();
|
||||
|
||||
if (streaming) {
|
||||
playback.stop();
|
||||
}
|
||||
|
||||
prefs.edit().putBoolean(Prefs.Key.STREAMING_PLAYBACK, !streaming).apply();
|
||||
|
||||
final int messageId = streaming
|
||||
? R.string.snackbar_remote_enabled
|
||||
: R.string.snackbar_streaming_enabled;
|
||||
|
||||
showSnackbar(messageId);
|
||||
|
||||
reloadPlaybackService();
|
||||
this.playback = getPlaybackService();
|
||||
|
||||
supportInvalidateOptionsMenu();
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
private void showSnackbar(int stringId) {
|
||||
final Snackbar sb = Snackbar.make(mainLayout, stringId, Snackbar.LENGTH_LONG);
|
||||
final View sbView = sb.getView();
|
||||
sbView.setBackgroundColor(ContextCompat.getColor(this, R.color.color_primary));
|
||||
final TextView tv = (TextView) sbView.findViewById(android.support.design.R.id.snackbar_text);
|
||||
tv.setTextColor(ContextCompat.getColor(this, R.color.theme_foreground));
|
||||
sb.show();
|
||||
}
|
||||
|
||||
private void bindCheckBoxEventListeners() {
|
||||
this.shuffleCb.setOnCheckedChangeListener(shuffleListener);
|
||||
this.muteCb.setOnCheckedChangeListener(muteListener);
|
||||
this.repeatCb.setOnCheckedChangeListener(repeatListener);
|
||||
}
|
||||
|
||||
/* onRestoreInstanceState() calls setChecked(), which has the side effect of
|
||||
running these callbacks. this screws up state, especially for the repeat checkbox */
|
||||
private void unbindCheckboxEventListeners() {
|
||||
this.shuffleCb.setOnCheckedChangeListener(null);
|
||||
this.muteCb.setOnCheckedChangeListener(null);
|
||||
this.repeatCb.setOnCheckedChangeListener(null);
|
||||
}
|
||||
|
||||
private void bindEventListeners() {
|
||||
this.mainLayout = findViewById(R.id.activity_main);
|
||||
this.metadataView = (MainMetadataView) findViewById(R.id.main_metadata_view);
|
||||
this.shuffleCb = (CheckBox) findViewById(R.id.check_shuffle);
|
||||
this.muteCb = (CheckBox) findViewById(R.id.check_mute);
|
||||
this.repeatCb = (CheckBox) findViewById(R.id.check_repeat);
|
||||
this.connectedNotPlayingContainer = findViewById(R.id.connected_not_playing);
|
||||
this.disconnectedButton = findViewById(R.id.disconnected_button);
|
||||
this.disconnectedContainer = findViewById(R.id.disconnected_container);
|
||||
this.disconnectedOverlay = findViewById(R.id.disconnected_overlay);
|
||||
this.showOfflineButton = findViewById(R.id.offline_tracks_button);
|
||||
this.playPause = (TextView) findViewById(R.id.button_play_pause);
|
||||
this.currentTime = (TextView) findViewById(R.id.current_time);
|
||||
this.totalTime = (TextView) findViewById(R.id.total_time);
|
||||
this.seekbar = (SeekBar) findViewById(R.id.seekbar);
|
||||
|
||||
findViewById(R.id.button_prev).setOnClickListener((View view) -> playback.prev());
|
||||
|
||||
findViewById(R.id.button_play_pause).setOnClickListener((View view) -> {
|
||||
if (playback.getPlaybackState() == PlaybackState.Stopped) {
|
||||
playback.playAll();
|
||||
}
|
||||
else {
|
||||
playback.pauseOrResume();
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.button_next).setOnClickListener((View view) -> playback.next());
|
||||
|
||||
disconnectedButton.setOnClickListener((view) -> {
|
||||
wss.reconnect();
|
||||
});
|
||||
|
||||
seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
|
||||
if (fromUser) {
|
||||
seekbarValue = progress;
|
||||
currentTime.setText(Duration.format(seekbarValue));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(SeekBar seekBar) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(SeekBar seekBar) {
|
||||
if (seekbarValue != -1) {
|
||||
playback.seekTo((double) seekbarValue);
|
||||
seekbarValue = -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.button_artists).setOnClickListener((View view) -> {
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.ALBUM_ARTIST));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_tracks).setOnClickListener((View view) -> {
|
||||
startActivity(TrackListActivity.getStartIntent(MainActivity.this));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_albums).setOnClickListener((View view) -> {
|
||||
startActivity(AlbumBrowseActivity.getStartIntent(MainActivity.this));
|
||||
});
|
||||
|
||||
findViewById(R.id.button_play_queue).setOnClickListener((view) -> navigateToPlayQueue());
|
||||
|
||||
findViewById(R.id.metadata_container).setOnClickListener((view) -> {
|
||||
if (playback.getQueueCount() > 0) {
|
||||
navigateToPlayQueue();
|
||||
}
|
||||
});
|
||||
|
||||
disconnectedOverlay.setOnClickListener(view -> {
|
||||
/* swallow input so user can't click on things while disconnected */
|
||||
});
|
||||
|
||||
showOfflineButton.setOnClickListener(view -> onOfflineTracksSelected());
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
if (this.playback == null) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
final PlaybackState playbackState = playback.getPlaybackState();
|
||||
final boolean streaming = prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK);
|
||||
final boolean connected = (wss.getState() == WebSocketService.State.Connected);
|
||||
final boolean stopped = (playbackState == PlaybackState.Stopped);
|
||||
final boolean playing = (playbackState == PlaybackState.Playing);
|
||||
final boolean buffering = (playbackState == PlaybackState.Buffering);
|
||||
final boolean showMetadataView = !stopped && /*connected &&*/ playback.getQueueCount() > 0;
|
||||
|
||||
/* bottom section: transport controls */
|
||||
this.playPause.setText(playing || buffering ? R.string.button_pause : R.string.button_play);
|
||||
|
||||
this.connectedNotPlayingContainer.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE);
|
||||
this.disconnectedOverlay.setVisibility(connected || !stopped ? View.GONE : View.VISIBLE);
|
||||
|
||||
final RepeatMode repeatMode = playback.getRepeatMode();
|
||||
final boolean repeatChecked = (repeatMode != RepeatMode.None);
|
||||
repeatCb.setText(REPEAT_TO_STRING_ID.get(repeatMode));
|
||||
Views.setCheckWithoutEvent(repeatCb, repeatChecked, this.repeatListener);
|
||||
|
||||
this.shuffleCb.setText(streaming ? R.string.button_random : R.string.button_shuffle);
|
||||
Views.setCheckWithoutEvent(this.shuffleCb, playback.isShuffled(), this.shuffleListener);
|
||||
|
||||
Views.setCheckWithoutEvent(this.muteCb, playback.isMuted(), this.muteListener);
|
||||
|
||||
/* middle section: connected, disconnected, and metadata views */
|
||||
connectedNotPlayingContainer.setVisibility(View.GONE);
|
||||
disconnectedContainer.setVisibility(View.GONE);
|
||||
|
||||
if (!showMetadataView) {
|
||||
metadataView.hide();
|
||||
|
||||
if (!connected) {
|
||||
disconnectedContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else if (stopped) {
|
||||
connectedNotPlayingContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
else {
|
||||
metadataView.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void clearUi() {
|
||||
metadataView.clear();
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
private void navigateToPlayQueue() {
|
||||
startActivity(PlayQueueActivity.getStartIntent(MainActivity.this, playback.getQueuePosition()));
|
||||
}
|
||||
|
||||
private void scheduleUpdateTime(boolean immediate) {
|
||||
handler.removeCallbacks(updateTimeRunnable);
|
||||
handler.postDelayed(updateTimeRunnable, immediate ? 0 : 1000);
|
||||
}
|
||||
|
||||
private Runnable updateTimeRunnable = () -> {
|
||||
final double duration = playback.getDuration();
|
||||
final double current = (seekbarValue == -1) ? playback.getCurrentTime() : seekbarValue;
|
||||
|
||||
currentTime.setText(Duration.format(current));
|
||||
totalTime.setText(Duration.format(duration));
|
||||
|
||||
seekbar.setMax((int) duration);
|
||||
seekbar.setProgress((int) current);
|
||||
seekbar.setSecondaryProgress((int) playback.getBufferedTime());
|
||||
|
||||
int currentTimeColor = R.color.theme_foreground;
|
||||
if (playback.getPlaybackState() == PlaybackState.Paused) {
|
||||
currentTimeColor = ++blink % 2 == 0
|
||||
? R.color.theme_foreground
|
||||
: R.color.theme_blink_foreground;
|
||||
}
|
||||
|
||||
currentTime.setTextColor(ContextCompat.getColor(this, currentTimeColor));
|
||||
|
||||
scheduleUpdateTime(false);
|
||||
};
|
||||
|
||||
private CheckBox.OnCheckedChangeListener muteListener =
|
||||
(CompoundButton compoundButton, boolean b) -> {
|
||||
if (b != playback.isMuted()) {
|
||||
playback.toggleMute();
|
||||
}
|
||||
};
|
||||
|
||||
private CheckBox.OnCheckedChangeListener shuffleListener =
|
||||
(CompoundButton compoundButton, boolean b) -> {
|
||||
if (b != playback.isShuffled()) {
|
||||
playback.toggleShuffle();
|
||||
}
|
||||
};
|
||||
|
||||
final CheckBox.OnCheckedChangeListener repeatListener = new CompoundButton.OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
|
||||
final RepeatMode currentMode = playback.getRepeatMode();
|
||||
|
||||
RepeatMode newMode = RepeatMode.None;
|
||||
|
||||
if (currentMode == RepeatMode.None) {
|
||||
newMode = RepeatMode.List;
|
||||
}
|
||||
else if (currentMode == RepeatMode.List) {
|
||||
newMode = RepeatMode.Track;
|
||||
}
|
||||
|
||||
final boolean checked = (newMode != RepeatMode.None);
|
||||
compoundButton.setText(REPEAT_TO_STRING_ID.get(newMode));
|
||||
Views.setCheckWithoutEvent(repeatCb, checked, this);
|
||||
|
||||
playback.toggleRepeatMode();
|
||||
}
|
||||
};
|
||||
|
||||
private PlaybackService.EventListener playbackEvents = () -> rebindUi();
|
||||
|
||||
private WebSocketService.Client serviceClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
rebindUi();
|
||||
}
|
||||
else if (newState == WebSocketService.State.Disconnected) {
|
||||
clearUi();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
final String tag = InvalidPasswordDialogFragment.TAG;
|
||||
if (getSupportFragmentManager().findFragmentByTag(tag) == null) {
|
||||
InvalidPasswordDialogFragment.newInstance().show(getSupportFragmentManager(), tag);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static class SwitchToOfflineTracksDialog extends DialogFragment {
|
||||
public static final String TAG = "switch_to_offline_tracks_dialog";
|
||||
|
||||
public static SwitchToOfflineTracksDialog newInstance() {
|
||||
return new SwitchToOfflineTracksDialog();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final AlertDialog dlg = new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.main_switch_to_streaming_title)
|
||||
.setMessage(R.string.main_switch_to_streaming_message)
|
||||
.setNegativeButton(R.string.button_no, null)
|
||||
.setPositiveButton(R.string.button_yes, (dialog, which) -> {
|
||||
((MainActivity) getActivity()).onConfirmSwitchToOfflineTracks();
|
||||
})
|
||||
.create();
|
||||
|
||||
dlg.setCancelable(false);
|
||||
return dlg;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,468 @@
|
||||
package io.casey.musikcube.remote
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.playback.PlaybackState
|
||||
import io.casey.musikcube.remote.playback.RepeatMode
|
||||
import io.casey.musikcube.remote.ui.activity.AlbumBrowseActivity
|
||||
import io.casey.musikcube.remote.ui.activity.CategoryBrowseActivity
|
||||
import io.casey.musikcube.remote.ui.activity.PlayQueueActivity
|
||||
import io.casey.musikcube.remote.ui.activity.SettingsActivity
|
||||
import io.casey.musikcube.remote.ui.activity.TrackListActivity
|
||||
import io.casey.musikcube.remote.ui.activity.WebSocketActivityBase
|
||||
import io.casey.musikcube.remote.ui.fragment.InvalidPasswordDialogFragment
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.ui.view.MainMetadataView
|
||||
import io.casey.musikcube.remote.util.Duration
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
class MainActivity : WebSocketActivityBase() {
|
||||
private val handler = Handler()
|
||||
private var prefs: SharedPreferences? = null
|
||||
private var playback: PlaybackService? = null
|
||||
|
||||
private var mainLayout: View? = null
|
||||
private var metadataView: MainMetadataView? = null
|
||||
private var playPause: TextView? = null
|
||||
private var currentTime: TextView? = null
|
||||
private var totalTime: TextView? = null
|
||||
private var connectedNotPlayingContainer: View? = null
|
||||
private var disconnectedButton: View? = null
|
||||
private var showOfflineButton: View? = null
|
||||
private var disconnectedContainer: View? = null
|
||||
private var shuffleCb: CheckBox? = null
|
||||
private var muteCb: CheckBox? = null
|
||||
private var repeatCb: CheckBox? = null
|
||||
private var disconnectedOverlay: View? = null
|
||||
private var seekbar: SeekBar? = null
|
||||
private var seekbarValue = -1
|
||||
private var blink = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
prefs = this.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
playback = playbackService
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
bindEventListeners()
|
||||
|
||||
if (!getWebSocketService().hasValidConnection()) {
|
||||
startActivity(SettingsActivity.getStartIntent(this))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
metadataView?.onPause()
|
||||
unbindCheckboxEventListeners()
|
||||
handler.removeCallbacks(updateTimeRunnable)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
this.playback = playbackService
|
||||
metadataView?.onResume()
|
||||
bindCheckBoxEventListeners()
|
||||
rebindUi()
|
||||
scheduleUpdateTime(true)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.main_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val connected = getWebSocketService().state === WebSocketService.State.Connected
|
||||
val streaming = isStreamingSelected
|
||||
|
||||
menu.findItem(R.id.action_playlists).isEnabled = connected
|
||||
menu.findItem(R.id.action_genres).isEnabled = connected
|
||||
|
||||
menu.findItem(R.id.action_remote_toggle).setIcon(
|
||||
if (streaming) R.mipmap.ic_toolbar_streaming else R.mipmap.ic_toolbar_remote)
|
||||
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_remote_toggle -> {
|
||||
togglePlaybackService()
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_settings -> {
|
||||
startActivity(SettingsActivity.getStartIntent(this))
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_genres -> {
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.GENRE))
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_playlists -> {
|
||||
startActivity(CategoryBrowseActivity.getStartIntent(
|
||||
this, Messages.Category.PLAYLISTS, CategoryBrowseActivity.DeepLink.TRACKS))
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_offline_tracks -> {
|
||||
onOfflineTracksSelected()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override val webSocketServiceClient: WebSocketService.Client?
|
||||
get() = serviceClient
|
||||
|
||||
override val playbackServiceEventListener: (() -> Unit)?
|
||||
get() = playbackEvents
|
||||
|
||||
private fun onOfflineTracksSelected() {
|
||||
if (isStreamingSelected) {
|
||||
startActivity(TrackListActivity.getOfflineStartIntent(this))
|
||||
}
|
||||
else {
|
||||
val tag = SwitchToOfflineTracksDialog.TAG
|
||||
if (supportFragmentManager.findFragmentByTag(tag) == null) {
|
||||
SwitchToOfflineTracksDialog.newInstance().show(supportFragmentManager, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConfirmSwitchToOfflineTracks() {
|
||||
togglePlaybackService()
|
||||
onOfflineTracksSelected()
|
||||
}
|
||||
|
||||
private val isStreamingSelected: Boolean
|
||||
get() = prefs!!.getBoolean(
|
||||
Prefs.Key.STREAMING_PLAYBACK,
|
||||
Prefs.Default.STREAMING_PLAYBACK)
|
||||
|
||||
private fun togglePlaybackService() {
|
||||
val streaming = isStreamingSelected
|
||||
|
||||
if (streaming) {
|
||||
playback?.stop()
|
||||
}
|
||||
|
||||
prefs?.edit()?.putBoolean(Prefs.Key.STREAMING_PLAYBACK, !streaming)?.apply()
|
||||
|
||||
val messageId = if (streaming)
|
||||
R.string.snackbar_remote_enabled
|
||||
else
|
||||
R.string.snackbar_streaming_enabled
|
||||
|
||||
showSnackbar(messageId)
|
||||
|
||||
reloadPlaybackService()
|
||||
playback = playbackService
|
||||
|
||||
supportInvalidateOptionsMenu()
|
||||
rebindUi()
|
||||
}
|
||||
|
||||
private fun showSnackbar(stringId: Int) {
|
||||
val sb = Snackbar.make(mainLayout!!, stringId, Snackbar.LENGTH_LONG)
|
||||
val sbView = sb.view
|
||||
sbView.setBackgroundColor(ContextCompat.getColor(this, R.color.color_primary))
|
||||
val tv = sbView.findViewById(android.support.design.R.id.snackbar_text) as TextView
|
||||
tv.setTextColor(ContextCompat.getColor(this, R.color.theme_foreground))
|
||||
sb.show()
|
||||
}
|
||||
|
||||
private fun bindCheckBoxEventListeners() {
|
||||
this.shuffleCb?.setOnCheckedChangeListener(shuffleListener)
|
||||
this.muteCb?.setOnCheckedChangeListener(muteListener)
|
||||
this.repeatCb?.setOnCheckedChangeListener(repeatListener)
|
||||
}
|
||||
|
||||
/* onRestoreInstanceState() calls setChecked(), which has the side effect of
|
||||
running these callbacks. this screws up state, especially for the repeat checkbox */
|
||||
private fun unbindCheckboxEventListeners() {
|
||||
this.shuffleCb?.setOnCheckedChangeListener(null)
|
||||
this.muteCb?.setOnCheckedChangeListener(null)
|
||||
this.repeatCb?.setOnCheckedChangeListener(null)
|
||||
}
|
||||
|
||||
private fun bindEventListeners() {
|
||||
this.mainLayout = findViewById(R.id.activity_main)
|
||||
this.metadataView = findViewById(R.id.main_metadata_view) as MainMetadataView
|
||||
this.shuffleCb = findViewById(R.id.check_shuffle) as CheckBox
|
||||
this.muteCb = findViewById(R.id.check_mute) as CheckBox
|
||||
this.repeatCb = findViewById(R.id.check_repeat) as CheckBox
|
||||
this.connectedNotPlayingContainer = findViewById(R.id.connected_not_playing)
|
||||
this.disconnectedButton = findViewById(R.id.disconnected_button)
|
||||
this.disconnectedContainer = findViewById(R.id.disconnected_container)
|
||||
this.disconnectedOverlay = findViewById(R.id.disconnected_overlay)
|
||||
this.showOfflineButton = findViewById(R.id.offline_tracks_button)
|
||||
this.playPause = findViewById(R.id.button_play_pause) as TextView
|
||||
this.currentTime = findViewById(R.id.current_time) as TextView
|
||||
this.totalTime = findViewById(R.id.total_time) as TextView
|
||||
this.seekbar = findViewById(R.id.seekbar) as SeekBar
|
||||
|
||||
findViewById(R.id.button_prev).setOnClickListener { _: View -> playback?.prev() }
|
||||
|
||||
findViewById(R.id.button_play_pause).setOnClickListener { _: View ->
|
||||
if (playback?.playbackState === PlaybackState.Stopped) {
|
||||
playback?.playAll()
|
||||
}
|
||||
else {
|
||||
playback?.pauseOrResume()
|
||||
}
|
||||
}
|
||||
|
||||
findViewById(R.id.button_next).setOnClickListener { _: View -> playback?.next() }
|
||||
|
||||
disconnectedButton?.setOnClickListener { _ -> getWebSocketService().reconnect() }
|
||||
|
||||
seekbar?.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
seekbarValue = progress
|
||||
currentTime?.text = Duration.format(seekbarValue.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
if (seekbarValue != -1) {
|
||||
playback?.seekTo(seekbarValue.toDouble())
|
||||
seekbarValue = -1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
findViewById(R.id.button_artists).setOnClickListener { _: View -> startActivity(CategoryBrowseActivity.getStartIntent(this, Messages.Category.ALBUM_ARTIST)) }
|
||||
|
||||
findViewById(R.id.button_tracks).setOnClickListener { _: View -> startActivity(TrackListActivity.getStartIntent(this@MainActivity)) }
|
||||
|
||||
findViewById(R.id.button_albums).setOnClickListener { _: View -> startActivity(AlbumBrowseActivity.getStartIntent(this@MainActivity)) }
|
||||
|
||||
findViewById(R.id.button_play_queue).setOnClickListener { _ -> navigateToPlayQueue() }
|
||||
|
||||
findViewById(R.id.metadata_container).setOnClickListener { _ ->
|
||||
if (playback?.queueCount ?: 0 > 0) {
|
||||
navigateToPlayQueue()
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedOverlay?.setOnClickListener { _ ->
|
||||
/* swallow input so user can't click on things while disconnected */
|
||||
}
|
||||
|
||||
showOfflineButton?.setOnClickListener { _ -> onOfflineTracksSelected() }
|
||||
}
|
||||
|
||||
private fun rebindUi() {
|
||||
if (this.playback == null) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
val playbackState = playback?.playbackState
|
||||
val streaming = prefs!!.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK)
|
||||
val connected = getWebSocketService().state === WebSocketService.State.Connected
|
||||
val stopped = playbackState === PlaybackState.Stopped
|
||||
val playing = playbackState === PlaybackState.Playing
|
||||
val buffering = playbackState === PlaybackState.Buffering
|
||||
val showMetadataView = !stopped && (playback?.queueCount ?: 0) > 0
|
||||
|
||||
/* bottom section: transport controls */
|
||||
this.playPause?.setText(if (playing || buffering) R.string.button_pause else R.string.button_play)
|
||||
|
||||
this.connectedNotPlayingContainer?.visibility = if (connected && stopped) View.VISIBLE else View.GONE
|
||||
this.disconnectedOverlay?.visibility = if (connected || !stopped) View.GONE else View.VISIBLE
|
||||
|
||||
val repeatMode = playback?.repeatMode
|
||||
val repeatChecked = repeatMode !== RepeatMode.None
|
||||
repeatCb?.text = getString(REPEAT_TO_STRING_ID[repeatMode] ?: R.string.unknown_value)
|
||||
repeatCb?.setCheckWithoutEvent(repeatChecked, this.repeatListener)
|
||||
|
||||
this.shuffleCb?.text = getString(if (streaming) R.string.button_random else R.string.button_shuffle)
|
||||
shuffleCb?.setCheckWithoutEvent(playback?.isShuffled ?: false, shuffleListener)
|
||||
|
||||
muteCb?.setCheckWithoutEvent(playback?.isMuted ?: false, muteListener)
|
||||
|
||||
/* middle section: connected, disconnected, and metadata views */
|
||||
connectedNotPlayingContainer?.visibility = View.GONE
|
||||
disconnectedContainer?.visibility = View.GONE
|
||||
|
||||
if (!showMetadataView) {
|
||||
metadataView?.hide()
|
||||
|
||||
if (!connected) {
|
||||
disconnectedContainer?.visibility = View.VISIBLE
|
||||
}
|
||||
else if (stopped) {
|
||||
connectedNotPlayingContainer?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
else {
|
||||
metadataView?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearUi() {
|
||||
metadataView?.clear()
|
||||
rebindUi()
|
||||
}
|
||||
|
||||
private fun navigateToPlayQueue() {
|
||||
startActivity(PlayQueueActivity.getStartIntent(this@MainActivity, playback?.queuePosition ?: 0))
|
||||
}
|
||||
|
||||
private fun scheduleUpdateTime(immediate: Boolean) {
|
||||
handler.removeCallbacks(updateTimeRunnable)
|
||||
handler.postDelayed(updateTimeRunnable, (if (immediate) 0 else 1000).toLong())
|
||||
}
|
||||
|
||||
private val updateTimeRunnable = {
|
||||
val duration = playback?.duration ?: 0.0
|
||||
val current: Double = if (seekbarValue == -1) playback?.currentTime ?: 0.0 else seekbarValue.toDouble()
|
||||
|
||||
currentTime?.text = Duration.format(current)
|
||||
totalTime?.text = Duration.format(duration)
|
||||
seekbar?.max = duration.toInt()
|
||||
seekbar?.progress = current.toInt()
|
||||
seekbar?.secondaryProgress = playback?.bufferedTime?.toInt() ?: 0
|
||||
|
||||
var currentTimeColor = R.color.theme_foreground
|
||||
if (playback?.playbackState === PlaybackState.Paused) {
|
||||
currentTimeColor = if (++blink % 2 == 0)
|
||||
R.color.theme_foreground
|
||||
else
|
||||
R.color.theme_blink_foreground
|
||||
}
|
||||
|
||||
currentTime?.setTextColor(ContextCompat.getColor(this, currentTimeColor))
|
||||
|
||||
scheduleUpdateTime(false)
|
||||
}
|
||||
|
||||
private val muteListener = { _: CompoundButton, b: Boolean ->
|
||||
if (b != playback?.isMuted) {
|
||||
playback?.toggleMute()
|
||||
}
|
||||
}
|
||||
|
||||
private val shuffleListener = { _: CompoundButton, b: Boolean ->
|
||||
if (b != playback?.isShuffled) {
|
||||
playback?.toggleShuffle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRepeatListener() {
|
||||
val currentMode = playback?.repeatMode
|
||||
|
||||
var newMode = RepeatMode.None
|
||||
|
||||
if (currentMode === RepeatMode.None) {
|
||||
newMode = RepeatMode.List
|
||||
}
|
||||
else if (currentMode === RepeatMode.List) {
|
||||
newMode = RepeatMode.Track
|
||||
}
|
||||
|
||||
val checked = newMode !== RepeatMode.None
|
||||
repeatCb?.text = getString(REPEAT_TO_STRING_ID[newMode] ?: R.string.unknown_value)
|
||||
repeatCb?.setCheckWithoutEvent(checked, repeatListener)
|
||||
|
||||
playback?.toggleRepeatMode()
|
||||
}
|
||||
|
||||
private val repeatListener = { _: CompoundButton, _: Boolean ->
|
||||
onRepeatListener()
|
||||
}
|
||||
|
||||
private val playbackEvents = { rebindUi() }
|
||||
|
||||
private val serviceClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState === WebSocketService.State.Connected) {
|
||||
rebindUi()
|
||||
}
|
||||
else if (newState === WebSocketService.State.Disconnected) {
|
||||
clearUi()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
|
||||
override fun onInvalidPassword() {
|
||||
val tag = InvalidPasswordDialogFragment.TAG
|
||||
if (supportFragmentManager.findFragmentByTag(tag) == null) {
|
||||
InvalidPasswordDialogFragment.newInstance().show(supportFragmentManager, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SwitchToOfflineTracksDialog : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dlg = AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.main_switch_to_streaming_title)
|
||||
.setMessage(R.string.main_switch_to_streaming_message)
|
||||
.setNegativeButton(R.string.button_no, null)
|
||||
.setPositiveButton(R.string.button_yes) { _, _ -> (activity as MainActivity).onConfirmSwitchToOfflineTracks() }
|
||||
.create()
|
||||
|
||||
dlg.setCancelable(false)
|
||||
return dlg
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "switch_to_offline_tracks_dialog"
|
||||
|
||||
fun newInstance(): SwitchToOfflineTracksDialog {
|
||||
return SwitchToOfflineTracksDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var REPEAT_TO_STRING_ID: MutableMap<RepeatMode, Int> = mutableMapOf(
|
||||
RepeatMode.None to R.string.button_repeat_off,
|
||||
RepeatMode.List to R.string.button_repeat_list,
|
||||
RepeatMode.Track to R.string.button_repeat_track
|
||||
)
|
||||
|
||||
fun getStartIntent(context: Context): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
package io.casey.musikcube.remote.offline;
|
||||
|
||||
import android.arch.persistence.room.Database;
|
||||
import android.arch.persistence.room.RoomDatabase;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.casey.musikcube.remote.Application;
|
||||
import io.casey.musikcube.remote.playback.StreamProxy;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
import io.reactivex.Single;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
@Database(entities = {OfflineTrack.class}, version = 1)
|
||||
public abstract class OfflineDb extends RoomDatabase {
|
||||
public OfflineDb() {
|
||||
WebSocketService.getInstance(Application.Companion.getInstance())
|
||||
.addInterceptor((message, responder) -> {
|
||||
if (Messages.Request.QueryTracksByCategory.is(message.getName())) {
|
||||
final String category = message.getStringOption(Messages.Key.CATEGORY);
|
||||
if (Messages.Category.OFFLINE.equals(category)) {
|
||||
queryTracks(message, responder);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
prune();
|
||||
}
|
||||
|
||||
public abstract OfflineTrackDao trackDao();
|
||||
|
||||
public void prune() {
|
||||
Single.fromCallable(() -> {
|
||||
List<String> uris = trackDao().queryUris();
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
|
||||
for (final String uri : uris) {
|
||||
if (!StreamProxy.Companion.isCached(uri)) {
|
||||
toDelete.add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete.size() > 0) {
|
||||
trackDao().deleteByUri(toDelete);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
public void queryTracks(final SocketMessage message, final WebSocketService.Responder responder) {
|
||||
Single.fromCallable(() -> {
|
||||
final OfflineTrackDao dao = trackDao();
|
||||
|
||||
final boolean countOnly = message.getBooleanOption(Messages.Key.COUNT_ONLY, false);
|
||||
final String filter = message.getStringOption(Messages.Key.FILTER, "");
|
||||
|
||||
final JSONArray tracks = new JSONArray();
|
||||
final JSONObject options = new JSONObject();
|
||||
|
||||
if (countOnly) {
|
||||
final int count = Strings.empty(filter) ? dao.countTracks() : dao.countTracks(filter);
|
||||
options.put(Messages.Key.COUNT, count);
|
||||
}
|
||||
else {
|
||||
final int offset = message.getIntOption(Messages.Key.OFFSET, -1);
|
||||
final int limit = message.getIntOption(Messages.Key.LIMIT, -1);
|
||||
|
||||
List<OfflineTrack> offlineTracks;
|
||||
|
||||
if (Strings.empty(filter)) {
|
||||
offlineTracks = (offset == -1 || limit == -1)
|
||||
? dao.queryTracks() : dao.queryTracks(limit, offset);
|
||||
}
|
||||
else {
|
||||
offlineTracks = (offset == -1 || limit == -1)
|
||||
? dao.queryTracks(filter) : dao.queryTracks(filter, limit, offset);
|
||||
}
|
||||
|
||||
for (final OfflineTrack track : offlineTracks) {
|
||||
tracks.put(track.toJSONObject());
|
||||
}
|
||||
|
||||
options.put(Messages.Key.OFFSET, offset);
|
||||
options.put(Messages.Key.LIMIT, limit);
|
||||
}
|
||||
|
||||
options.put(Messages.Key.DATA, tracks);
|
||||
|
||||
final SocketMessage response = SocketMessage.Builder
|
||||
.respondTo(message).withOptions(options).build();
|
||||
|
||||
responder.respond(response);
|
||||
|
||||
return true;
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe();
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
package io.casey.musikcube.remote.offline
|
||||
|
||||
import android.arch.persistence.room.Database
|
||||
import android.arch.persistence.room.RoomDatabase
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
import java.util.ArrayList
|
||||
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.playback.StreamProxy
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
|
||||
@Database(entities = arrayOf(OfflineTrack::class), version = 1)
|
||||
abstract class OfflineDb : RoomDatabase() {
|
||||
init {
|
||||
WebSocketService.getInstance(Application.instance!!)
|
||||
.addInterceptor({ message, responder ->
|
||||
var result = false
|
||||
if (Messages.Request.QueryTracksByCategory.matches(message.name)) {
|
||||
val category = message.getStringOption(Messages.Key.CATEGORY)
|
||||
if (Messages.Category.OFFLINE == category) {
|
||||
queryTracks(message, responder)
|
||||
result = true
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
|
||||
prune()
|
||||
}
|
||||
|
||||
abstract fun trackDao(): OfflineTrackDao
|
||||
|
||||
fun prune() {
|
||||
Single.fromCallable {
|
||||
val uris = trackDao().queryUris()
|
||||
val toDelete = ArrayList<String>()
|
||||
|
||||
uris.forEach {
|
||||
if (!StreamProxy.isCached(it)) {
|
||||
toDelete.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete.size > 0) {
|
||||
trackDao().deleteByUri(toDelete)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
fun queryTracks(message: SocketMessage, responder: WebSocketService.Responder) {
|
||||
Single.fromCallable {
|
||||
val dao = trackDao()
|
||||
|
||||
val countOnly = message.getBooleanOption(Messages.Key.COUNT_ONLY, false)
|
||||
val filter = message.getStringOption(Messages.Key.FILTER, "")
|
||||
|
||||
val tracks = JSONArray()
|
||||
val options = JSONObject()
|
||||
|
||||
if (countOnly) {
|
||||
val count = if (Strings.empty(filter)) dao.countTracks() else dao.countTracks(filter)
|
||||
options.put(Messages.Key.COUNT, count)
|
||||
}
|
||||
else {
|
||||
val offset = message.getIntOption(Messages.Key.OFFSET, -1)
|
||||
val limit = message.getIntOption(Messages.Key.LIMIT, -1)
|
||||
|
||||
val offlineTracks: List<OfflineTrack>
|
||||
|
||||
if (Strings.empty(filter)) {
|
||||
offlineTracks = if (offset == -1 || limit == -1)
|
||||
dao.queryTracks() else dao.queryTracks(limit, offset)
|
||||
}
|
||||
else {
|
||||
offlineTracks = if (offset == -1 || limit == -1)
|
||||
dao.queryTracks(filter) else dao.queryTracks(filter, limit, offset)
|
||||
}
|
||||
|
||||
for (track in offlineTracks) {
|
||||
tracks.put(track.toJSONObject())
|
||||
}
|
||||
|
||||
options.put(Messages.Key.OFFSET, offset)
|
||||
options.put(Messages.Key.LIMIT, limit)
|
||||
}
|
||||
|
||||
options.put(Messages.Key.DATA, tracks)
|
||||
|
||||
val response = SocketMessage.Builder
|
||||
.respondTo(message).withOptions(options).build()
|
||||
|
||||
responder.respond(response)
|
||||
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
package io.casey.musikcube.remote.offline;
|
||||
|
||||
import android.arch.persistence.room.Entity;
|
||||
import android.arch.persistence.room.PrimaryKey;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
|
||||
@Entity
|
||||
public class OfflineTrack {
|
||||
@PrimaryKey
|
||||
public String externalId;
|
||||
|
||||
public String uri, title, album, artist, albumArtist, genre;
|
||||
public long albumId, artistId, albumArtistId, genreId;
|
||||
public int trackNum;
|
||||
|
||||
public boolean fromJSONObject(final String uri, final JSONObject from) {
|
||||
if (Strings.empty(uri)) {
|
||||
throw new IllegalArgumentException("uri cannot be empty");
|
||||
}
|
||||
|
||||
final String externalId = from.optString(Metadata.Track.EXTERNAL_ID, "");
|
||||
if (Strings.empty(externalId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.externalId = externalId;
|
||||
this.uri = uri;
|
||||
this.title = from.optString(Metadata.Track.TITLE, "");
|
||||
this.album = from.optString(Metadata.Track.ALBUM, "");
|
||||
this.artist = from.optString(Metadata.Track.ARTIST, "");
|
||||
this.albumArtist = from.optString(Metadata.Track.ALBUM_ARTIST, "");
|
||||
this.genre = from.optString(Metadata.Track.GENRE, "");
|
||||
this.albumId = from.optLong(Metadata.Track.ALBUM_ID, -1L);
|
||||
this.artistId = from.optLong(Metadata.Track.ARTIST_ID, -1L);
|
||||
this.albumArtistId = from.optLong(Metadata.Track.ALBUM_ARTIST_ID, -1L);
|
||||
this.genreId = from.optLong(Metadata.Track.GENRE_ID, -1L);
|
||||
this.trackNum = from.optInt(Metadata.Track.TRACK_NUM, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
JSONObject toJSONObject() {
|
||||
try {
|
||||
final JSONObject json = new JSONObject();
|
||||
json.put(Metadata.Track.TITLE, title);
|
||||
json.put(Metadata.Track.ALBUM, album);
|
||||
json.put(Metadata.Track.ARTIST, artist);
|
||||
json.put(Metadata.Track.ALBUM_ARTIST, albumArtist);
|
||||
json.put(Metadata.Track.GENRE, genre);
|
||||
json.put(Metadata.Track.ALBUM_ID, albumId);
|
||||
json.put(Metadata.Track.ARTIST_ID, artistId);
|
||||
json.put(Metadata.Track.ALBUM_ARTIST_ID, albumArtistId);
|
||||
json.put(Metadata.Track.GENRE_ID, genreId);
|
||||
json.put(Metadata.Track.TRACK_NUM, trackNum);
|
||||
json.put(Metadata.Track.EXTERNAL_ID, externalId);
|
||||
json.put(Metadata.Track.URI, uri);
|
||||
return json;
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException("json serialization error");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package io.casey.musikcube.remote.offline
|
||||
|
||||
import android.arch.persistence.room.Entity
|
||||
import android.arch.persistence.room.PrimaryKey
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
|
||||
@Entity
|
||||
class OfflineTrack {
|
||||
@PrimaryKey
|
||||
var externalId: String = ""
|
||||
|
||||
var uri: String = ""
|
||||
var title: String = ""
|
||||
var album: String = ""
|
||||
var artist: String = ""
|
||||
var albumArtist: String = ""
|
||||
var genre: String = ""
|
||||
var albumId: Long = 0
|
||||
var artistId: Long = 0
|
||||
var albumArtistId: Long = 0
|
||||
var genreId: Long = 0
|
||||
var trackNum: Int = 0
|
||||
|
||||
fun fromJSONObject(uri: String, from: JSONObject): Boolean {
|
||||
if (Strings.empty(uri)) {
|
||||
throw IllegalArgumentException("uri cannot be empty")
|
||||
}
|
||||
|
||||
val externalId = from.optString(Metadata.Track.EXTERNAL_ID, "")
|
||||
if (Strings.empty(externalId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.externalId = externalId
|
||||
this.uri = uri
|
||||
this.title = from.optString(Metadata.Track.TITLE, "")
|
||||
this.album = from.optString(Metadata.Track.ALBUM, "")
|
||||
this.artist = from.optString(Metadata.Track.ARTIST, "")
|
||||
this.albumArtist = from.optString(Metadata.Track.ALBUM_ARTIST, "")
|
||||
this.genre = from.optString(Metadata.Track.GENRE, "")
|
||||
this.albumId = from.optLong(Metadata.Track.ALBUM_ID, -1L)
|
||||
this.artistId = from.optLong(Metadata.Track.ARTIST_ID, -1L)
|
||||
this.albumArtistId = from.optLong(Metadata.Track.ALBUM_ARTIST_ID, -1L)
|
||||
this.genreId = from.optLong(Metadata.Track.GENRE_ID, -1L)
|
||||
this.trackNum = from.optInt(Metadata.Track.TRACK_NUM, 0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun toJSONObject(): JSONObject {
|
||||
try {
|
||||
val json = JSONObject()
|
||||
json.put(Metadata.Track.TITLE, title)
|
||||
json.put(Metadata.Track.ALBUM, album)
|
||||
json.put(Metadata.Track.ARTIST, artist)
|
||||
json.put(Metadata.Track.ALBUM_ARTIST, albumArtist)
|
||||
json.put(Metadata.Track.GENRE, genre)
|
||||
json.put(Metadata.Track.ALBUM_ID, albumId)
|
||||
json.put(Metadata.Track.ARTIST_ID, artistId)
|
||||
json.put(Metadata.Track.ALBUM_ARTIST_ID, albumArtistId)
|
||||
json.put(Metadata.Track.GENRE_ID, genreId)
|
||||
json.put(Metadata.Track.TRACK_NUM, trackNum)
|
||||
json.put(Metadata.Track.EXTERNAL_ID, externalId)
|
||||
json.put(Metadata.Track.URI, uri)
|
||||
return json
|
||||
} catch (ex: JSONException) {
|
||||
throw RuntimeException("json serialization error")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -161,20 +161,24 @@ class ExoPlayerWrapper : PlayerWrapper() {
|
||||
override fun resume() {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
when (this.state) {
|
||||
State.Paused, State.Prepared -> {
|
||||
this.player!!.playWhenReady = true
|
||||
prefetch = false
|
||||
|
||||
when (state) {
|
||||
State.Paused,
|
||||
State.Prepared -> {
|
||||
player!!.playWhenReady = true
|
||||
state = State.Playing
|
||||
}
|
||||
|
||||
State.Error -> {
|
||||
this.player!!.playWhenReady = this.lastPosition == -1L
|
||||
this.player.prepare(this.source)
|
||||
player!!.playWhenReady = lastPosition == -1L
|
||||
player.prepare(source)
|
||||
state = State.Preparing
|
||||
}
|
||||
|
||||
else -> { }
|
||||
}
|
||||
|
||||
this.prefetch = false
|
||||
}
|
||||
|
||||
override var position: Int
|
||||
@ -241,10 +245,6 @@ class ExoPlayerWrapper : PlayerWrapper() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun setOnStateChangedListener(listener: PlayerWrapper.OnStateChangedListener?) {
|
||||
super.setOnStateChangedListener(listener)
|
||||
}
|
||||
|
||||
private fun dead(): Boolean {
|
||||
val state = state
|
||||
return state == State.Killing || state == State.Disposed
|
||||
@ -341,6 +341,8 @@ class ExoPlayerWrapper : PlayerWrapper() {
|
||||
State.Playing,
|
||||
State.Paused ->
|
||||
state = State.Error
|
||||
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,24 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public class MediaButtonReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
final String action = intent.getAction();
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(action)) {
|
||||
final KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
||||
|
||||
if (event != null && event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
|
||||
context.startService(new Intent(context, SystemService.class));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
|
||||
class MediaButtonReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (Intent.ACTION_MEDIA_BUTTON == action) {
|
||||
val event = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
||||
|
||||
if (event != null && event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE ->
|
||||
context.startService(Intent(context, SystemService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
public class Metadata {
|
||||
public interface Track {
|
||||
String ID = "id";
|
||||
String EXTERNAL_ID = "external_id";
|
||||
String URI = "uri";
|
||||
String TITLE = "title";
|
||||
String ALBUM = "album";
|
||||
String ALBUM_ID = "album_id";
|
||||
String ALBUM_ARTIST = "album_artist";
|
||||
String ALBUM_ARTIST_ID = "album_artist_id";
|
||||
String GENRE = "genre";
|
||||
String TRACK_NUM = "track_num";
|
||||
String GENRE_ID = "visual_genre_id";
|
||||
String ARTIST = "artist";
|
||||
String ARTIST_ID = "visual_artist_id";
|
||||
}
|
||||
|
||||
public interface Album {
|
||||
String TITLE = "title";
|
||||
String ALBUM_ARTIST = "album_artist";
|
||||
}
|
||||
|
||||
private Metadata() {
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
object Metadata {
|
||||
interface Track {
|
||||
companion object {
|
||||
val ID = "id"
|
||||
val EXTERNAL_ID = "external_id"
|
||||
val URI = "uri"
|
||||
val TITLE = "title"
|
||||
val ALBUM = "album"
|
||||
val ALBUM_ID = "album_id"
|
||||
val ALBUM_ARTIST = "album_artist"
|
||||
val ALBUM_ARTIST_ID = "album_artist_id"
|
||||
val GENRE = "genre"
|
||||
val TRACK_NUM = "track_num"
|
||||
val GENRE_ID = "visual_genre_id"
|
||||
val ARTIST = "artist"
|
||||
val ARTIST_ID = "visual_artist_id"
|
||||
}
|
||||
}
|
||||
|
||||
interface Album {
|
||||
companion object {
|
||||
val TITLE = "title"
|
||||
val ALBUM_ARTIST = "album_artist"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow;
|
||||
|
||||
public interface PlaybackService {
|
||||
interface EventListener {
|
||||
void onStateUpdated();
|
||||
}
|
||||
|
||||
void connect(final EventListener listener);
|
||||
void disconnect(final EventListener listener);
|
||||
|
||||
void playAll();
|
||||
|
||||
void playAll(final int index, final String filter);
|
||||
|
||||
void play(
|
||||
final String category,
|
||||
final long categoryId,
|
||||
final int index,
|
||||
final String filter);
|
||||
|
||||
void playAt(final int index);
|
||||
|
||||
void pauseOrResume();
|
||||
|
||||
void pause();
|
||||
void resume();
|
||||
void prev();
|
||||
void next();
|
||||
void stop();
|
||||
|
||||
void volumeUp();
|
||||
void volumeDown();
|
||||
|
||||
void seekForward();
|
||||
void seekBackward();
|
||||
void seekTo(double seconds);
|
||||
|
||||
int getQueueCount();
|
||||
int getQueuePosition();
|
||||
|
||||
double getVolume();
|
||||
double getDuration();
|
||||
double getCurrentTime();
|
||||
double getBufferedTime();
|
||||
|
||||
PlaybackState getPlaybackState();
|
||||
|
||||
void toggleShuffle();
|
||||
boolean isShuffled();
|
||||
|
||||
void toggleMute();
|
||||
boolean isMuted();
|
||||
|
||||
void toggleRepeatMode();
|
||||
RepeatMode getRepeatMode();
|
||||
|
||||
TrackListSlidingWindow.QueryFactory getPlaylistQueryFactory();
|
||||
|
||||
String getTrackString(final String key, final String defaultValue);
|
||||
long getTrackLong(final String key, final long defaultValue);
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow
|
||||
|
||||
interface PlaybackService {
|
||||
fun connect(listener: () -> Unit)
|
||||
fun disconnect(listener: () -> Unit)
|
||||
|
||||
fun playAll()
|
||||
|
||||
fun playAll(index: Int, filter: String)
|
||||
|
||||
fun play(
|
||||
category: String,
|
||||
categoryId: Long,
|
||||
index: Int,
|
||||
filter: String)
|
||||
|
||||
fun playAt(index: Int)
|
||||
|
||||
fun pauseOrResume()
|
||||
|
||||
fun pause()
|
||||
fun resume()
|
||||
fun prev()
|
||||
operator fun next()
|
||||
fun stop()
|
||||
|
||||
fun volumeUp()
|
||||
fun volumeDown()
|
||||
|
||||
fun seekForward()
|
||||
fun seekBackward()
|
||||
fun seekTo(seconds: Double)
|
||||
|
||||
val queueCount: Int
|
||||
val queuePosition: Int
|
||||
|
||||
val volume: Double
|
||||
val duration: Double
|
||||
val currentTime: Double
|
||||
val bufferedTime: Double
|
||||
|
||||
val playbackState: PlaybackState
|
||||
|
||||
fun toggleShuffle()
|
||||
val isShuffled: Boolean
|
||||
|
||||
fun toggleMute()
|
||||
val isMuted: Boolean
|
||||
|
||||
fun toggleRepeatMode()
|
||||
val repeatMode: RepeatMode
|
||||
|
||||
val playlistQueryFactory: TrackListSlidingWindow.QueryFactory
|
||||
|
||||
fun getTrackString(key: String, defaultValue: String): String
|
||||
fun getTrackLong(key: String, defaultValue: Long): Long
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
|
||||
public class PlaybackServiceFactory {
|
||||
private static StreamingPlaybackService streaming;
|
||||
private static RemotePlaybackService remote;
|
||||
private static SharedPreferences prefs;
|
||||
|
||||
public static synchronized PlaybackService instance(final Context context) {
|
||||
init(context);
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK)) {
|
||||
return streaming;
|
||||
}
|
||||
|
||||
return remote;
|
||||
}
|
||||
|
||||
public static synchronized StreamingPlaybackService streaming(final Context context) {
|
||||
init(context);
|
||||
return streaming;
|
||||
}
|
||||
|
||||
public static synchronized RemotePlaybackService remote(final Context context) {
|
||||
init(context);
|
||||
return remote;
|
||||
}
|
||||
|
||||
private static void init(final Context context) {
|
||||
if (streaming == null || remote == null || prefs == null) {
|
||||
prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
streaming = new StreamingPlaybackService(context);
|
||||
remote = new RemotePlaybackService(context);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
|
||||
object PlaybackServiceFactory {
|
||||
private var streaming: StreamingPlaybackService? = null
|
||||
private var remote: RemotePlaybackService? = null
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
@Synchronized fun instance(context: Context): PlaybackService {
|
||||
init(context)
|
||||
|
||||
if (prefs!!.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK)) {
|
||||
return streaming!!
|
||||
}
|
||||
|
||||
return remote!!
|
||||
}
|
||||
|
||||
@Synchronized fun streaming(context: Context): StreamingPlaybackService {
|
||||
init(context)
|
||||
return streaming!!
|
||||
}
|
||||
|
||||
@Synchronized fun remote(context: Context): RemotePlaybackService {
|
||||
init(context)
|
||||
return remote!!
|
||||
}
|
||||
|
||||
private fun init(context: Context) {
|
||||
if (streaming == null || remote == null || prefs == null) {
|
||||
prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
streaming = StreamingPlaybackService(context)
|
||||
remote = RemotePlaybackService(context)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
public enum PlaybackState {
|
||||
Stopped("stopped"),
|
||||
Buffering("buffering"), /* streaming only */
|
||||
Playing("playing"),
|
||||
Paused("paused");
|
||||
|
||||
private final String rawValue;
|
||||
|
||||
PlaybackState(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
static PlaybackState from(final String rawValue) {
|
||||
if (Stopped.rawValue.equals(rawValue) || "unknown".equals(rawValue)) {
|
||||
return Stopped;
|
||||
}
|
||||
else if (Playing.rawValue.equals(rawValue)) {
|
||||
return Playing;
|
||||
}
|
||||
else if (Paused.rawValue.equals(rawValue)) {
|
||||
return Paused;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("rawValue is invalid");
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
enum class PlaybackState constructor(private val rawValue: String) {
|
||||
Stopped("stopped"),
|
||||
Buffering("buffering"), /* streaming only */
|
||||
Playing("playing"),
|
||||
Paused("paused");
|
||||
|
||||
override fun toString(): String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun from(rawValue: String): PlaybackState {
|
||||
if (Stopped.rawValue == rawValue || "unknown" == rawValue) {
|
||||
return Stopped
|
||||
}
|
||||
else if (Playing.rawValue == rawValue) {
|
||||
return Playing
|
||||
}
|
||||
else if (Paused.rawValue == rawValue) {
|
||||
return Paused
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("rawValue matches invalid")
|
||||
}
|
||||
}
|
||||
}
|
@ -28,19 +28,13 @@ abstract class PlayerWrapper {
|
||||
Disposed
|
||||
}
|
||||
|
||||
interface OnStateChangedListener {
|
||||
fun onStateChanged(mpw: PlayerWrapper, state: State)
|
||||
}
|
||||
|
||||
private var listener: OnStateChangedListener? = null
|
||||
private var listener: ((PlayerWrapper, State) -> Unit)? = null
|
||||
|
||||
var state = State.Stopped
|
||||
protected set(state) {
|
||||
if (this.state != state) {
|
||||
field = state
|
||||
if (listener != null) {
|
||||
this.listener!!.onStateChanged(this, state)
|
||||
}
|
||||
listener?.invoke(this,state)
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,14 +49,10 @@ abstract class PlayerWrapper {
|
||||
abstract val duration: Int
|
||||
abstract val bufferedPercent: Int
|
||||
|
||||
open fun setOnStateChangedListener(listener: OnStateChangedListener?) {
|
||||
open fun setOnStateChangedListener(listener: ((PlayerWrapper, State) -> Unit)?) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
this.listener = listener
|
||||
|
||||
if (listener != null) {
|
||||
this.listener!!.onStateChanged(this, this.state)
|
||||
}
|
||||
this.listener?.invoke(this, this.state)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@ -109,16 +99,16 @@ abstract class PlayerWrapper {
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
var volume = volume
|
||||
var computedVolume = volume
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
if (preDuckGlobalVolume != DUCK_NONE) {
|
||||
preDuckGlobalVolume = volume
|
||||
volume *= DUCK_COEF
|
||||
computedVolume *= DUCK_COEF
|
||||
}
|
||||
|
||||
if (volume != globalVolume) {
|
||||
globalVolume = volume
|
||||
if (computedVolume != globalVolume) {
|
||||
globalVolume = computedVolume
|
||||
for (w in activePlayers) {
|
||||
w.updateVolume()
|
||||
}
|
||||
|
@ -1,478 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class RemotePlaybackService implements PlaybackService {
|
||||
private static final double NANOSECONDS_PER_SECOND = 1000000000.0;
|
||||
private static final long SYNC_TIME_INTERVAL_MS = 5000L;
|
||||
|
||||
private interface Key {
|
||||
String STATE = "state";
|
||||
String REPEAT_MODE = "repeat_mode";
|
||||
String VOLUME = "volume";
|
||||
String SHUFFLED = "shuffled";
|
||||
String MUTED = "muted";
|
||||
String PLAY_QUEUE_COUNT = "track_count";
|
||||
String PLAY_QUEUE_POSITION = "play_queue_position";
|
||||
String PLAYING_DURATION = "playing_duration";
|
||||
String PLAYING_CURRENT_TIME = "playing_current_time";
|
||||
String PLAYING_TRACK = "playing_track";
|
||||
}
|
||||
|
||||
/**
|
||||
* an annoying little class that maintains and updates state that estimates
|
||||
* the currently playing time. remember, here we're a remote control, so we
|
||||
* don't know the exact position of the play head! we update every 5 seconds
|
||||
* and estimate.
|
||||
*/
|
||||
private static class EstimatedPosition {
|
||||
double lastTime = 0.0, pauseTime = 0.0;
|
||||
long trackId = -1;
|
||||
long queryTime = 0;
|
||||
|
||||
double get(final JSONObject track) {
|
||||
if (track != null && track.optLong(Metadata.Track.ID, -1L) == trackId && trackId != -1) {
|
||||
if (pauseTime != 0) {
|
||||
return pauseTime;
|
||||
}
|
||||
else {
|
||||
return estimatedTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void update(final SocketMessage message) {
|
||||
queryTime = System.nanoTime();
|
||||
lastTime = message.getDoubleOption(Messages.Key.PLAYING_CURRENT_TIME, 0);
|
||||
trackId = message.getLongOption(Messages.Key.ID, -1);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
pauseTime = estimatedTime();
|
||||
}
|
||||
|
||||
void resume() {
|
||||
lastTime = pauseTime;
|
||||
queryTime = System.nanoTime();
|
||||
pauseTime = 0.0;
|
||||
}
|
||||
|
||||
void update(final double time, final long id) {
|
||||
queryTime = System.nanoTime();
|
||||
lastTime = time;
|
||||
trackId = id;
|
||||
|
||||
if (pauseTime != 0) {
|
||||
pauseTime = time; /* ehh... */
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
lastTime = pauseTime = 0.0;
|
||||
queryTime = System.nanoTime();
|
||||
trackId = -1;
|
||||
}
|
||||
|
||||
double estimatedTime() {
|
||||
final long diff = System.nanoTime() - queryTime;
|
||||
final double seconds = (double) diff / NANOSECONDS_PER_SECOND;
|
||||
return lastTime + seconds;
|
||||
}
|
||||
}
|
||||
|
||||
private Handler handler = new Handler();
|
||||
private WebSocketService wss;
|
||||
private EstimatedPosition currentTime = new EstimatedPosition();
|
||||
private PlaybackState playbackState = PlaybackState.Stopped;
|
||||
private Set<EventListener> listeners = new HashSet<>();
|
||||
private RepeatMode repeatMode;
|
||||
private boolean shuffled;
|
||||
private boolean muted;
|
||||
private double volume;
|
||||
private int queueCount;
|
||||
private int queuePosition;
|
||||
private double duration;
|
||||
private JSONObject track = new JSONObject();
|
||||
|
||||
public RemotePlaybackService(final Context context) {
|
||||
wss = WebSocketService.getInstance(context.getApplicationContext());
|
||||
reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAll() {
|
||||
playAll(0, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAll(final int index, final String filter) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.PlayAllTracks)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void play(String category, long categoryId, int index, String filter) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.PlayTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prev() {
|
||||
wss.send(SocketMessage.Builder.request(Messages.Request.Previous).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pauseOrResume() {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.PauseOrResume).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause() {
|
||||
if (playbackState != PlaybackState.Paused) {
|
||||
pauseOrResume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resume() {
|
||||
if (playbackState != PlaybackState.Playing) {
|
||||
pauseOrResume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
/* nothing for now */
|
||||
}
|
||||
|
||||
@Override
|
||||
public void next() {
|
||||
wss.send(SocketMessage.Builder.request(Messages.Request.Next).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAt(int index) {
|
||||
wss.send(SocketMessage
|
||||
.Builder.request(Messages.Request.PlayAtIndex)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void volumeUp() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(Messages.Key.RELATIVE, Messages.Value.UP)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void volumeDown() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(Messages.Key.RELATIVE, Messages.Value.DOWN)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekForward() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, 5.0f).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekBackward() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, -5.0f).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekTo(double seconds) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekTo)
|
||||
.addOption(Messages.Key.POSITION, seconds).build());
|
||||
|
||||
currentTime.update(seconds, currentTime.trackId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getQueueCount() {
|
||||
return queueCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getQueuePosition() {
|
||||
return queuePosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlaybackState getPlaybackState() {
|
||||
return playbackState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepeatMode getRepeatMode() {
|
||||
return repeatMode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void connect(EventListener listener) {
|
||||
if (listener != null) {
|
||||
listeners.add(listener);
|
||||
|
||||
if (listeners.size() == 1) {
|
||||
wss.addClient(client);
|
||||
scheduleTimeSyncMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void disconnect(EventListener listener) {
|
||||
if (listener != null) {
|
||||
listeners.remove(listener);
|
||||
|
||||
if (listeners.size() == 0) {
|
||||
wss.removeClient(client);
|
||||
handler.removeCallbacks(syncTimeRunnable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleShuffle() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleShuffle).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleMute() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleMute).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleRepeatMode() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleRepeat)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShuffled() {
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMuted() {
|
||||
return muted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getVolume() {
|
||||
return volume;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getCurrentTime() {
|
||||
return currentTime.get(track);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getBufferedTime() {
|
||||
return getDuration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTrackString(String key, String defaultValue) {
|
||||
if (track.has(key)) {
|
||||
return track.optString(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTrackLong(String key, long defaultValue) {
|
||||
if (track.has(key)) {
|
||||
return track.optLong(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrackListSlidingWindow.QueryFactory getPlaylistQueryFactory() {
|
||||
return queryFactory;
|
||||
}
|
||||
|
||||
private void reset() {
|
||||
playbackState = PlaybackState.Stopped;
|
||||
repeatMode = RepeatMode.None;
|
||||
shuffled = muted = false;
|
||||
volume = 0.0f;
|
||||
queueCount = queuePosition = 0;
|
||||
track = new JSONObject();
|
||||
currentTime.reset();
|
||||
}
|
||||
|
||||
private boolean isPlaybackOverviewMessage(SocketMessage socketMessage) {
|
||||
if (socketMessage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name = socketMessage.getName();
|
||||
|
||||
return
|
||||
name.equals(Messages.Broadcast.PlaybackOverviewChanged.toString()) ||
|
||||
name.equals(Messages.Request.GetPlaybackOverview.toString());
|
||||
}
|
||||
|
||||
private boolean updatePlaybackOverview(SocketMessage message) {
|
||||
if (message == null) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
final String name = message.getName();
|
||||
|
||||
if (!name.equals(Messages.Broadcast.PlaybackOverviewChanged.toString()) &&
|
||||
!name.equals(Messages.Request.GetPlaybackOverview.toString()))
|
||||
{
|
||||
throw new IllegalArgumentException("invalid message!");
|
||||
}
|
||||
|
||||
playbackState = PlaybackState.from(message.getStringOption(Key.STATE));
|
||||
|
||||
switch (playbackState) {
|
||||
case Paused:
|
||||
currentTime.pause();
|
||||
break;
|
||||
case Playing:
|
||||
currentTime.resume();
|
||||
scheduleTimeSyncMessage();
|
||||
break;
|
||||
}
|
||||
|
||||
repeatMode = RepeatMode.from(message.getStringOption(Key.REPEAT_MODE));
|
||||
shuffled = message.getBooleanOption(Key.SHUFFLED);
|
||||
muted = message.getBooleanOption(Key.MUTED);
|
||||
volume = message.getDoubleOption(Key.VOLUME);
|
||||
queueCount = message.getIntOption(Key.PLAY_QUEUE_COUNT);
|
||||
queuePosition = message.getIntOption(Key.PLAY_QUEUE_POSITION);
|
||||
duration = message.getDoubleOption(Key.PLAYING_DURATION);
|
||||
track = message.getJsonObjectOption(Key.PLAYING_TRACK, new JSONObject());
|
||||
|
||||
if (track != null) {
|
||||
currentTime.update(
|
||||
message.getDoubleOption(Key.PLAYING_CURRENT_TIME, -1),
|
||||
track.optLong(Metadata.Track.ID, -1));
|
||||
}
|
||||
|
||||
notifyStateUpdated();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private synchronized void notifyStateUpdated() {
|
||||
for (final EventListener listener : listeners) {
|
||||
listener.onStateUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleTimeSyncMessage() {
|
||||
handler.removeCallbacks(syncTimeRunnable);
|
||||
|
||||
if (getPlaybackState() == PlaybackState.Playing) {
|
||||
handler.postDelayed(syncTimeRunnable, SYNC_TIME_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private final Runnable syncTimeRunnable = () -> {
|
||||
if (this.wss.hasClient(this.client)) {
|
||||
this.wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.GetCurrentTime).build());
|
||||
}
|
||||
};
|
||||
|
||||
private final TrackListSlidingWindow.QueryFactory queryFactory
|
||||
= new TrackListSlidingWindow.QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean connectionRequired() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client client = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.GetPlaybackOverview.toString()).build());
|
||||
}
|
||||
else if (newState == WebSocketService.State.Disconnected) {
|
||||
reset();
|
||||
notifyStateUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
if (isPlaybackOverviewMessage(message)) {
|
||||
updatePlaybackOverview(message);
|
||||
}
|
||||
else if (Messages.Request.GetCurrentTime.is(message.getName())) {
|
||||
currentTime.update(message);
|
||||
scheduleTimeSyncMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,430 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
import java.util.HashSet
|
||||
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
class RemotePlaybackService(context: Context) : PlaybackService {
|
||||
private interface Key {
|
||||
companion object {
|
||||
val STATE = "state"
|
||||
val REPEAT_MODE = "repeat_mode"
|
||||
val VOLUME = "volume"
|
||||
val SHUFFLED = "shuffled"
|
||||
val MUTED = "muted"
|
||||
val PLAY_QUEUE_COUNT = "track_count"
|
||||
val PLAY_QUEUE_POSITION = "play_queue_position"
|
||||
val PLAYING_DURATION = "playing_duration"
|
||||
val PLAYING_CURRENT_TIME = "playing_current_time"
|
||||
val PLAYING_TRACK = "playing_track"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* an annoying little class that maintains and updates state that estimates
|
||||
* the currently playing time. remember, here we're a remote control, so we
|
||||
* don't know the exact position of the play head! we update every 5 seconds
|
||||
* and estimate.
|
||||
*/
|
||||
private class EstimatedPosition {
|
||||
internal var lastTime = 0.0
|
||||
internal var pauseTime = 0.0
|
||||
internal var trackId: Long = -1
|
||||
internal var queryTime: Long = 0
|
||||
|
||||
internal fun get(track: JSONObject?): Double {
|
||||
if (track != null && track.optLong(Metadata.Track.ID, -1L) == trackId && trackId != -1L) {
|
||||
if (pauseTime != 0.0) {
|
||||
return pauseTime
|
||||
}
|
||||
else {
|
||||
return estimatedTime()
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
internal fun update(message: SocketMessage) {
|
||||
queryTime = System.nanoTime()
|
||||
lastTime = message.getDoubleOption(Messages.Key.PLAYING_CURRENT_TIME, 0.0)
|
||||
trackId = message.getLongOption(Messages.Key.ID, -1)
|
||||
}
|
||||
|
||||
internal fun pause() {
|
||||
pauseTime = estimatedTime()
|
||||
}
|
||||
|
||||
internal fun resume() {
|
||||
lastTime = pauseTime
|
||||
queryTime = System.nanoTime()
|
||||
pauseTime = 0.0
|
||||
}
|
||||
|
||||
internal fun update(time: Double, id: Long) {
|
||||
queryTime = System.nanoTime()
|
||||
lastTime = time
|
||||
trackId = id
|
||||
|
||||
if (pauseTime != 0.0) {
|
||||
pauseTime = time /* ehh... */
|
||||
}
|
||||
}
|
||||
|
||||
internal fun reset() {
|
||||
pauseTime = 0.0
|
||||
lastTime = pauseTime
|
||||
queryTime = System.nanoTime()
|
||||
trackId = -1
|
||||
}
|
||||
|
||||
internal fun estimatedTime(): Double {
|
||||
val diff = System.nanoTime() - queryTime
|
||||
val seconds = diff.toDouble() / NANOSECONDS_PER_SECOND
|
||||
return lastTime + seconds
|
||||
}
|
||||
}
|
||||
|
||||
private val handler = Handler()
|
||||
private val wss: WebSocketService = WebSocketService.getInstance(context.applicationContext)
|
||||
private val listeners = HashSet<() -> Unit>()
|
||||
private val estimatedTime = EstimatedPosition()
|
||||
|
||||
override var playbackState = PlaybackState.Stopped
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override val currentTime: Double
|
||||
get() {
|
||||
return estimatedTime.get(track)
|
||||
}
|
||||
|
||||
override var repeatMode: RepeatMode = RepeatMode.None
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var isShuffled: Boolean = false
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var isMuted: Boolean = false
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var volume: Double = 0.0
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var queueCount: Int = 0
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var queuePosition: Int = 0
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var duration: Double = 0.0
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
private var track: JSONObject = JSONObject()
|
||||
|
||||
init {
|
||||
reset()
|
||||
}
|
||||
|
||||
override fun playAll() {
|
||||
playAll(0, "")
|
||||
}
|
||||
|
||||
override fun playAll(index: Int, filter: String) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.PlayAllTracks)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun play(category: String, categoryId: Long, index: Int, filter: String) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.PlayTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun prev() {
|
||||
wss.send(SocketMessage.Builder.request(Messages.Request.Previous).build())
|
||||
}
|
||||
|
||||
override fun pauseOrResume() {
|
||||
wss.send(SocketMessage.Builder.request(Messages.Request.PauseOrResume).build())
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (playbackState != PlaybackState.Paused) {
|
||||
pauseOrResume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
if (playbackState != PlaybackState.Playing) {
|
||||
pauseOrResume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
/* nothing for now */
|
||||
}
|
||||
|
||||
override fun next() {
|
||||
wss.send(SocketMessage.Builder.request(Messages.Request.Next).build())
|
||||
}
|
||||
|
||||
override fun playAt(index: Int) {
|
||||
wss.send(SocketMessage
|
||||
.Builder.request(Messages.Request.PlayAtIndex)
|
||||
.addOption(Messages.Key.INDEX, index)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun volumeUp() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(Messages.Key.RELATIVE, Messages.Value.UP)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun volumeDown() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SetVolume)
|
||||
.addOption(Messages.Key.RELATIVE, Messages.Value.DOWN)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, 5.0f).build())
|
||||
}
|
||||
|
||||
override fun seekBackward() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekRelative)
|
||||
.addOption(Messages.Key.DELTA, -5.0f).build())
|
||||
}
|
||||
|
||||
override fun seekTo(seconds: Double) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.SeekTo)
|
||||
.addOption(Messages.Key.POSITION, seconds).build())
|
||||
|
||||
estimatedTime.update(seconds, estimatedTime.trackId)
|
||||
}
|
||||
|
||||
@Synchronized override fun connect(listener: () -> Unit) {
|
||||
listeners.add(listener)
|
||||
|
||||
if (listeners.size == 1) {
|
||||
wss.addClient(client)
|
||||
scheduleTimeSyncMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized override fun disconnect(listener: () -> Unit) {
|
||||
listeners.remove(listener)
|
||||
|
||||
if (listeners.size == 0) {
|
||||
wss.removeClient(client)
|
||||
handler.removeCallbacks(syncTimeRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toggleShuffle() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleShuffle).build())
|
||||
}
|
||||
|
||||
override fun toggleMute() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleMute).build())
|
||||
}
|
||||
|
||||
override fun toggleRepeatMode() {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.ToggleRepeat)
|
||||
.build())
|
||||
}
|
||||
|
||||
override val bufferedTime: Double
|
||||
get() = duration
|
||||
|
||||
override fun getTrackString(key: String, defaultValue: String): String {
|
||||
if (track.has(key)) {
|
||||
return track.optString(key, defaultValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun getTrackLong(key: String, defaultValue: Long): Long {
|
||||
if (track.has(key)) {
|
||||
return track.optLong(key, defaultValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
playbackState = PlaybackState.Stopped
|
||||
repeatMode = RepeatMode.None
|
||||
isMuted = false
|
||||
isShuffled = isMuted
|
||||
volume = 0.0
|
||||
queuePosition = 0
|
||||
queueCount = queuePosition
|
||||
track = JSONObject()
|
||||
estimatedTime.reset()
|
||||
}
|
||||
|
||||
private fun isPlaybackOverviewMessage(socketMessage: SocketMessage?): Boolean {
|
||||
if (socketMessage == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val name = socketMessage.name
|
||||
|
||||
return Messages.Broadcast.PlaybackOverviewChanged.matches(name) ||
|
||||
Messages.Request.GetPlaybackOverview.matches(name)
|
||||
}
|
||||
|
||||
private fun updatePlaybackOverview(message: SocketMessage?): Boolean {
|
||||
if (message == null) {
|
||||
reset()
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isPlaybackOverviewMessage(message)) {
|
||||
throw IllegalArgumentException("invalid message!")
|
||||
}
|
||||
|
||||
playbackState = PlaybackState.from(message.getStringOption(Key.STATE))
|
||||
|
||||
when (playbackState) {
|
||||
PlaybackState.Paused -> estimatedTime.pause()
|
||||
PlaybackState.Playing -> {
|
||||
estimatedTime.resume()
|
||||
scheduleTimeSyncMessage()
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
|
||||
repeatMode = RepeatMode.from(message.getStringOption(Key.REPEAT_MODE))
|
||||
isShuffled = message.getBooleanOption(Key.SHUFFLED)
|
||||
isMuted = message.getBooleanOption(Key.MUTED)
|
||||
volume = message.getDoubleOption(Key.VOLUME)
|
||||
queueCount = message.getIntOption(Key.PLAY_QUEUE_COUNT)
|
||||
queuePosition = message.getIntOption(Key.PLAY_QUEUE_POSITION)
|
||||
duration = message.getDoubleOption(Key.PLAYING_DURATION)
|
||||
track = message.getJsonObjectOption(Key.PLAYING_TRACK, JSONObject()) ?: JSONObject()
|
||||
|
||||
estimatedTime.update(
|
||||
message.getDoubleOption(Key.PLAYING_CURRENT_TIME, -1.0),
|
||||
track.optLong(Metadata.Track.ID, -1))
|
||||
|
||||
notifyStateUpdated()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized private fun notifyStateUpdated() {
|
||||
for (listener in listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTimeSyncMessage() {
|
||||
handler.removeCallbacks(syncTimeRunnable)
|
||||
|
||||
if (playbackState == PlaybackState.Playing) {
|
||||
handler.postDelayed(syncTimeRunnable, SYNC_TIME_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private val syncTimeRunnable = object: Runnable {
|
||||
override fun run() {
|
||||
if (wss.hasClient(client)) {
|
||||
wss.send(SocketMessage.Builder
|
||||
.request(Messages.Request.GetCurrentTime).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() {
|
||||
override fun getRequeryMessage(): SocketMessage {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPageAroundMessage(offset: Int, limit: Int): SocketMessage {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun connectionRequired(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val client = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState === WebSocketService.State.Connected) {
|
||||
wss.send(SocketMessage.Builder.request(
|
||||
Messages.Request.GetPlaybackOverview.toString()).build())
|
||||
}
|
||||
else if (newState === WebSocketService.State.Disconnected) {
|
||||
reset()
|
||||
notifyStateUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {
|
||||
if (isPlaybackOverviewMessage(message)) {
|
||||
updatePlaybackOverview(message)
|
||||
}
|
||||
else if (Messages.Request.GetCurrentTime.matches(message.name)) {
|
||||
estimatedTime.update(message)
|
||||
scheduleTimeSyncMessage()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInvalidPassword() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val NANOSECONDS_PER_SECOND = 1000000000.0
|
||||
private val SYNC_TIME_INTERVAL_MS = 5000L
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
public enum RepeatMode {
|
||||
None("none"),
|
||||
List("list"),
|
||||
Track("track");
|
||||
|
||||
private final String rawValue;
|
||||
|
||||
RepeatMode(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public static RepeatMode from(final String rawValue) {
|
||||
if (None.rawValue.equals(rawValue)) {
|
||||
return None;
|
||||
} else if (List.rawValue.equals(rawValue)) {
|
||||
return List;
|
||||
} else if (Track.rawValue.equals(rawValue)) {
|
||||
return Track;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("rawValue is invalid");
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
enum class RepeatMode constructor(private val rawValue: String) {
|
||||
None("none"),
|
||||
List("list"),
|
||||
Track("track");
|
||||
|
||||
override fun toString(): String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(rawValue: String): RepeatMode {
|
||||
if (None.rawValue == rawValue) {
|
||||
return None
|
||||
}
|
||||
else if (List.rawValue == rawValue) {
|
||||
return List
|
||||
}
|
||||
else if (Track.rawValue == rawValue) {
|
||||
return Track
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("rawValue matches invalid")
|
||||
}
|
||||
}
|
||||
}
|
@ -16,11 +16,9 @@ import java.util.*
|
||||
|
||||
class StreamProxy private constructor(context: Context) {
|
||||
private val proxy: HttpProxyCacheServer
|
||||
private val prefs: SharedPreferences
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
|
||||
if (this.prefs.getBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, Prefs.Default.CERT_VALIDATION_DISABLED)) {
|
||||
NetworkUtil.disableCertificateValidation()
|
||||
}
|
||||
@ -40,7 +38,7 @@ class StreamProxy private constructor(context: Context) {
|
||||
proxy = HttpProxyCacheServer.Builder(context.applicationContext)
|
||||
.cacheDirectory(cachePath)
|
||||
.maxCacheSize(CACHE_SETTING_TO_BYTES[diskCacheIndex] ?: MINIMUM_CACHE_SIZE_BYTES)
|
||||
.headerInjector { url ->
|
||||
.headerInjector { _ ->
|
||||
val headers = HashMap<String, String>()
|
||||
val userPass = "default:" + prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD)!!
|
||||
val encoded = Base64.encodeToString(userPass.toByteArray(), Base64.NO_WRAP)
|
||||
@ -50,7 +48,7 @@ class StreamProxy private constructor(context: Context) {
|
||||
.fileNameGenerator gen@ { url ->
|
||||
try {
|
||||
val uri = Uri.parse(url)
|
||||
/* format is: audio/external_id/<id> */
|
||||
/* format matches: audio/external_id/<id> */
|
||||
val segments = uri.pathSegments
|
||||
if (segments.size == 3 && "external_id" == segments[1]) {
|
||||
/* url params, hyphen separated */
|
||||
@ -82,18 +80,16 @@ class StreamProxy private constructor(context: Context) {
|
||||
val BYTES_PER_MEGABYTE = 1048576L
|
||||
val BYTES_PER_GIGABYTE = 1073741824L
|
||||
val MINIMUM_CACHE_SIZE_BYTES = BYTES_PER_MEGABYTE * 128
|
||||
val CACHE_SETTING_TO_BYTES: MutableMap<Int, Long>
|
||||
private val DEFAULT_FILENAME_GENERATOR = Md5FileNameGenerator()
|
||||
|
||||
init {
|
||||
CACHE_SETTING_TO_BYTES = HashMap<Int, Long>()
|
||||
CACHE_SETTING_TO_BYTES.put(0, MINIMUM_CACHE_SIZE_BYTES)
|
||||
CACHE_SETTING_TO_BYTES.put(1, BYTES_PER_GIGABYTE / 2)
|
||||
CACHE_SETTING_TO_BYTES.put(2, BYTES_PER_GIGABYTE)
|
||||
CACHE_SETTING_TO_BYTES.put(3, BYTES_PER_GIGABYTE * 2)
|
||||
CACHE_SETTING_TO_BYTES.put(4, BYTES_PER_GIGABYTE * 3)
|
||||
CACHE_SETTING_TO_BYTES.put(5, BYTES_PER_GIGABYTE * 4)
|
||||
}
|
||||
val CACHE_SETTING_TO_BYTES: MutableMap<Int, Long> = mutableMapOf(
|
||||
0 to MINIMUM_CACHE_SIZE_BYTES,
|
||||
1 to BYTES_PER_GIGABYTE / 2,
|
||||
2 to BYTES_PER_GIGABYTE,
|
||||
3 to BYTES_PER_GIGABYTE * 2,
|
||||
4 to BYTES_PER_GIGABYTE * 3,
|
||||
5 to BYTES_PER_GIGABYTE * 4)
|
||||
|
||||
private val DEFAULT_FILENAME_GENERATOR = Md5FileNameGenerator()
|
||||
|
||||
private var INSTANCE: StreamProxy? = null
|
||||
|
||||
@ -104,31 +100,25 @@ class StreamProxy private constructor(context: Context) {
|
||||
}
|
||||
|
||||
@Synchronized fun registerCacheListener(cl: CacheListener, uri: String) {
|
||||
if (INSTANCE != null) {
|
||||
INSTANCE!!.proxy.registerCacheListener(cl, uri) /* let it throw */
|
||||
}
|
||||
INSTANCE?.proxy?.registerCacheListener(cl, uri) /* let it throw */
|
||||
}
|
||||
|
||||
@Synchronized fun unregisterCacheListener(cl: CacheListener) {
|
||||
if (INSTANCE != null) {
|
||||
INSTANCE!!.proxy.unregisterCacheListener(cl)
|
||||
}
|
||||
INSTANCE?.proxy?.unregisterCacheListener(cl)
|
||||
}
|
||||
|
||||
@Synchronized fun isCached(url: String): Boolean {
|
||||
return INSTANCE != null && INSTANCE!!.proxy.isCached(url)
|
||||
return INSTANCE?.proxy?.isCached(url)!!
|
||||
}
|
||||
|
||||
@Synchronized fun getProxyUrl(url: String): String {
|
||||
init(Application.instance!!)
|
||||
return if (ENABLED) INSTANCE!!.proxy.getProxyUrl(url) else url
|
||||
return if (ENABLED) INSTANCE?.proxy?.getProxyUrl(url)!! else url
|
||||
}
|
||||
|
||||
@Synchronized fun reload() {
|
||||
if (INSTANCE != null) {
|
||||
INSTANCE!!.proxy.shutdown()
|
||||
INSTANCE = null
|
||||
}
|
||||
INSTANCE?.proxy?.shutdown()
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,983 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Resources;
|
||||
import android.database.ContentObserver;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
|
||||
import io.casey.musikcube.remote.Application;
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
|
||||
public class StreamingPlaybackService implements PlaybackService {
|
||||
private static final String TAG = "StreamingPlayback";
|
||||
private static final String REPEAT_MODE_PREF = "streaming_playback_repeat_mode";
|
||||
private static final int PREV_TRACK_GRACE_PERIOD_MILLIS = 3500;
|
||||
private static final int MAX_TRACK_METADATA_CACHE_SIZE = 50;
|
||||
private static final int PRECACHE_METADATA_SIZE = 10;
|
||||
private static final int PAUSED_SERVICE_SLEEP_DELAY_MS = 1000 * 60 * 5; /* 5 minutes */
|
||||
|
||||
private WebSocketService wss;
|
||||
private Set<EventListener> listeners = new HashSet<>();
|
||||
private boolean shuffled, muted;
|
||||
private RepeatMode repeatMode = RepeatMode.None;
|
||||
private QueueParams params;
|
||||
private PlaybackContext context = new PlaybackContext();
|
||||
private PlaybackState state = PlaybackState.Stopped;
|
||||
private AudioManager audioManager;
|
||||
private SharedPreferences prefs;
|
||||
private int lastSystemVolume;
|
||||
private boolean pausedByTransientLoss = false;
|
||||
private Random random = new Random();
|
||||
private Handler handler = new Handler();
|
||||
|
||||
private Map<Integer, JSONObject> trackMetadataCache = new LinkedHashMap<Integer, JSONObject>() {
|
||||
protected boolean removeEldestEntry(Map.Entry<Integer, JSONObject> eldest) {
|
||||
return size() >= MAX_TRACK_METADATA_CACHE_SIZE;
|
||||
}
|
||||
};
|
||||
|
||||
private static class PlaybackContext {
|
||||
int queueCount;
|
||||
PlayerWrapper currentPlayer, nextPlayer;
|
||||
JSONObject currentMetadata, nextMetadata;
|
||||
int currentIndex = -1, nextIndex = -1;
|
||||
boolean nextPlayerScheduled;
|
||||
|
||||
public void stopPlaybackAndReset() {
|
||||
reset(currentPlayer);
|
||||
reset(nextPlayer);
|
||||
nextPlayerScheduled = false;
|
||||
this.currentPlayer = this.nextPlayer = null;
|
||||
this.currentMetadata = this.nextMetadata = null;
|
||||
this.currentIndex = this.nextIndex = -1;
|
||||
}
|
||||
|
||||
public void notifyNextTrackPrepared() {
|
||||
if (currentPlayer != null && nextPlayer != null) {
|
||||
currentPlayer.setNextMediaPlayer(nextPlayer);
|
||||
nextPlayerScheduled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean advanceToNextTrack(
|
||||
final PlayerWrapper.OnStateChangedListener currentTrackListener)
|
||||
{
|
||||
boolean startedNext = false;
|
||||
|
||||
if (nextMetadata != null && nextPlayer != null) {
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer.setOnStateChangedListener(null);
|
||||
currentPlayer.dispose();
|
||||
}
|
||||
|
||||
currentMetadata = nextMetadata;
|
||||
currentIndex = nextIndex;
|
||||
currentPlayer = nextPlayer;
|
||||
startedNext = true;
|
||||
}
|
||||
else {
|
||||
reset(currentPlayer);
|
||||
currentPlayer = null;
|
||||
currentMetadata = null;
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
nextPlayer = null;
|
||||
nextMetadata = null;
|
||||
nextIndex = -1;
|
||||
nextPlayerScheduled = false;
|
||||
|
||||
/* needs to be done after swapping current/next, otherwise event handlers
|
||||
will fire, and things may get cleaned up before we have a chance to start */
|
||||
if (startedNext) {
|
||||
currentPlayer.setOnStateChangedListener(currentTrackListener);
|
||||
currentPlayer.resume(); /* no-op if playing already */
|
||||
}
|
||||
|
||||
return startedNext;
|
||||
}
|
||||
|
||||
public void reset(final PlayerWrapper p) {
|
||||
if (p != null) {
|
||||
p.setOnStateChangedListener(null);
|
||||
p.dispose();
|
||||
|
||||
if (p == nextPlayer) {
|
||||
nextPlayerScheduled = false; /* uhh... */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class QueueParams {
|
||||
final String category;
|
||||
final long categoryId;
|
||||
final String filter;
|
||||
|
||||
public QueueParams(String filter) {
|
||||
this.filter = filter;
|
||||
this.categoryId = -1;
|
||||
this.category = null;
|
||||
}
|
||||
|
||||
public QueueParams(String category, long categoryId, String filter) {
|
||||
this.category = category;
|
||||
this.categoryId = categoryId;
|
||||
this.filter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
public StreamingPlaybackService(final Context context) {
|
||||
this.wss = WebSocketService.getInstance(context.getApplicationContext());
|
||||
this.prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
this.audioManager = (AudioManager) Application.Companion.getInstance().getSystemService(Context.AUDIO_SERVICE);
|
||||
this.lastSystemVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
|
||||
this.repeatMode = RepeatMode.from(this.prefs.getString(REPEAT_MODE_PREF, RepeatMode.None.toString()));
|
||||
this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||
context.getContentResolver().registerContentObserver(Settings.System.CONTENT_URI, true, new SettingsContentObserver());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void connect(EventListener listener) {
|
||||
if (listener != null) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size() == 1) {
|
||||
wss.addClient(wssClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void disconnect(EventListener listener) {
|
||||
if (listener != null) {
|
||||
listeners.remove(listener);
|
||||
if (listeners.size() == 0) {
|
||||
wss.removeClient(wssClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAll() {
|
||||
playAll(0, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAll(int index, String filter) {
|
||||
if (requestAudioFocus()) {
|
||||
trackMetadataCache.clear();
|
||||
loadQueueAndPlay(new QueueParams(filter), index);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void play(String category, long categoryId, int index, String filter) {
|
||||
if (requestAudioFocus()) {
|
||||
trackMetadataCache.clear();
|
||||
loadQueueAndPlay(new QueueParams(category, categoryId, filter), index);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playAt(int index) {
|
||||
if (requestAudioFocus()) {
|
||||
this.context.stopPlaybackAndReset();
|
||||
loadQueueAndPlay(this.params, index);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pauseOrResume() {
|
||||
if (context.currentPlayer != null) {
|
||||
if (state == PlaybackState.Playing || state == PlaybackState.Buffering) {
|
||||
pause();
|
||||
}
|
||||
else {
|
||||
resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause() {
|
||||
if (state != PlaybackState.Paused) {
|
||||
schedulePausedSleep();
|
||||
killAudioFocus();
|
||||
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.pause();
|
||||
}
|
||||
|
||||
setState(PlaybackState.Paused);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resume() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep();
|
||||
pausedByTransientLoss = false;
|
||||
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.resume();
|
||||
setState(PlaybackState.Playing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
SystemService.shutdown();
|
||||
killAudioFocus();
|
||||
this.context.stopPlaybackAndReset();
|
||||
this.trackMetadataCache.clear();
|
||||
setState(PlaybackState.Stopped);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prev() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep();
|
||||
|
||||
if (context.currentPlayer != null) {
|
||||
if (context.currentPlayer.getPosition() > PREV_TRACK_GRACE_PERIOD_MILLIS) {
|
||||
context.currentPlayer.setPosition(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
moveToPrevTrack();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void next() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep();
|
||||
moveToNextTrack(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void volumeUp() {
|
||||
adjustVolume(getVolumeStep());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void volumeDown() {
|
||||
adjustVolume(-getVolumeStep());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekForward() {
|
||||
if (requestAudioFocus()) {
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.setPosition(context.currentPlayer.getPosition() + 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekBackward() {
|
||||
if (requestAudioFocus()) {
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.setPosition(context.currentPlayer.getPosition() - 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekTo(double seconds) {
|
||||
if (requestAudioFocus()) {
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.setPosition((int)(seconds * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getQueueCount() {
|
||||
return context.queueCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getQueuePosition() {
|
||||
return context.currentIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getVolume() {
|
||||
if (prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME)) {
|
||||
return PlayerWrapper.Companion.getVolume();
|
||||
}
|
||||
|
||||
return getSystemVolume();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getDuration() {
|
||||
if (context.currentPlayer != null) {
|
||||
return (context.currentPlayer.getDuration() / 1000.0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getCurrentTime() {
|
||||
if (context.currentPlayer != null) {
|
||||
return (context.currentPlayer.getPosition() / 1000.0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlaybackState getPlaybackState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleShuffle() {
|
||||
shuffled = !shuffled;
|
||||
invalidateAndPrefetchNextTrackMetadata();
|
||||
notifyEventListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShuffled() {
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleMute() {
|
||||
muted = !muted;
|
||||
PlayerWrapper.Companion.setMute(muted);
|
||||
notifyEventListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMuted() {
|
||||
return muted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleRepeatMode() {
|
||||
switch (repeatMode) {
|
||||
case None: repeatMode = RepeatMode.List; break;
|
||||
case List: repeatMode = RepeatMode.Track; break;
|
||||
default: repeatMode = RepeatMode.None;
|
||||
}
|
||||
this.prefs.edit().putString(REPEAT_MODE_PREF, repeatMode.toString()).apply();
|
||||
invalidateAndPrefetchNextTrackMetadata();
|
||||
notifyEventListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepeatMode getRepeatMode() {
|
||||
return repeatMode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTrackString(String key, String defaultValue) {
|
||||
if (context.currentMetadata != null) {
|
||||
return context.currentMetadata.optString(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTrackLong(String key, long defaultValue) {
|
||||
if (context.currentMetadata != null) {
|
||||
return context.currentMetadata.optLong(key, defaultValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getBufferedTime() {
|
||||
if (context.currentPlayer != null) {
|
||||
float percent = (float) context.currentPlayer.getBufferedPercent() / 100.0f;
|
||||
return percent * (float) context.currentPlayer.getDuration() / 1000.0f; /* ms -> sec */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrackListSlidingWindow.QueryFactory getPlaylistQueryFactory() {
|
||||
return this.queryFactory;
|
||||
}
|
||||
|
||||
private void pauseTransient() {
|
||||
if (state != PlaybackState.Paused) {
|
||||
pausedByTransientLoss = true;
|
||||
setState(PlaybackState.Paused);
|
||||
context.currentPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private float getVolumeStep() {
|
||||
if (prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME)) {
|
||||
return 0.1f;
|
||||
}
|
||||
return 1.0f / getMaxSystemVolume();
|
||||
}
|
||||
|
||||
private void adjustVolume(float delta) {
|
||||
if (muted) {
|
||||
toggleMute();
|
||||
}
|
||||
|
||||
final boolean softwareVolume = prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME);
|
||||
float current = softwareVolume ? PlayerWrapper.Companion.getVolume() : getSystemVolume();
|
||||
|
||||
current += delta;
|
||||
if (current > 1.0) current = 1.0f;
|
||||
if (current < 0.0) current = 0.0f;
|
||||
|
||||
if (softwareVolume) {
|
||||
PlayerWrapper.Companion.setVolume(current);
|
||||
}
|
||||
else {
|
||||
final int actual = Math.round(current * getMaxSystemVolume());
|
||||
lastSystemVolume = actual;
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, actual, 0);
|
||||
}
|
||||
|
||||
notifyEventListeners();
|
||||
}
|
||||
|
||||
private float getSystemVolume() {
|
||||
float current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
|
||||
return current / getMaxSystemVolume();
|
||||
}
|
||||
|
||||
private float getMaxSystemVolume() {
|
||||
return audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
|
||||
}
|
||||
|
||||
private void killAudioFocus() {
|
||||
audioManager.abandonAudioFocus(audioFocusChangeListener);
|
||||
}
|
||||
|
||||
private boolean requestAudioFocus() {
|
||||
return
|
||||
audioManager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
|
||||
}
|
||||
|
||||
private void moveToPrevTrack() {
|
||||
if (this.context.queueCount > 0) {
|
||||
loadQueueAndPlay(this.params, resolvePrevIndex(
|
||||
this.context.currentIndex, this.context.queueCount));
|
||||
}
|
||||
}
|
||||
|
||||
private void moveToNextTrack(boolean userInitiated) {
|
||||
int index = context.currentIndex;
|
||||
if (!userInitiated && context.advanceToNextTrack(onCurrentPlayerStateChanged)) {
|
||||
notifyEventListeners();
|
||||
prefetchNextTrackMetadata();
|
||||
}
|
||||
else {
|
||||
/* failed! reset by loading as if the user selected the next track
|
||||
manually (this will automatically load the current and next tracks */
|
||||
final int next = resolveNextIndex(index, context.queueCount, userInitiated);
|
||||
if (next >= 0) {
|
||||
loadQueueAndPlay(params, next);
|
||||
}
|
||||
else {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerWrapper.OnStateChangedListener onCurrentPlayerStateChanged = (mpw, playerState) -> {
|
||||
switch (playerState) {
|
||||
case Playing:
|
||||
setState(PlaybackState.Playing);
|
||||
prefetchNextTrackAudio();
|
||||
cancelScheduledPausedSleep();
|
||||
precacheTrackMetadata(context.currentIndex, PRECACHE_METADATA_SIZE);
|
||||
break;
|
||||
|
||||
case Buffering:
|
||||
setState(PlaybackState.Buffering);
|
||||
break;
|
||||
|
||||
case Paused:
|
||||
pause();
|
||||
break;
|
||||
|
||||
case Error:
|
||||
pause();
|
||||
break;
|
||||
|
||||
case Finished:
|
||||
if (state != PlaybackState.Paused) {
|
||||
moveToNextTrack(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private PlayerWrapper.OnStateChangedListener onNextPlayerStateChanged = (mpw, playerState) -> {
|
||||
if (playerState == PlayerWrapper.State.Prepared) {
|
||||
if (mpw == context.nextPlayer) {
|
||||
context.notifyNextTrackPrepared();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void setState(PlaybackState state) {
|
||||
if (this.state != state) {
|
||||
Log.d(TAG, "state = " + state);
|
||||
this.state = state;
|
||||
notifyEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void notifyEventListeners() {
|
||||
for (final EventListener listener : listeners) {
|
||||
listener.onStateUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
private String getUri(final JSONObject track) {
|
||||
if (track != null) {
|
||||
final String existingUri = track.optString(Metadata.Track.URI, "");
|
||||
if (Strings.notEmpty(existingUri)) {
|
||||
return existingUri;
|
||||
}
|
||||
|
||||
final String externalId = track.optString(Metadata.Track.EXTERNAL_ID, "");
|
||||
if (Strings.notEmpty(externalId)) {
|
||||
final String protocol = prefs.getBoolean(
|
||||
Prefs.Key.SSL_ENABLED, Prefs.Default.SSL_ENABLED) ? "https" : "http";
|
||||
|
||||
/* transcoding bitrate, if selected by the user */
|
||||
String bitrateQueryParam = "";
|
||||
final int bitrateIndex = prefs.getInt(
|
||||
Prefs.Key.TRANSCODER_BITRATE_INDEX,
|
||||
Prefs.Default.TRANSCODER_BITRATE_INDEX);
|
||||
|
||||
if (bitrateIndex > 0) {
|
||||
final Resources r = Application.Companion.getInstance().getResources();
|
||||
|
||||
bitrateQueryParam = String.format(
|
||||
Locale.ENGLISH,
|
||||
"?bitrate=%s",
|
||||
r.getStringArray(R.array.transcode_bitrate_array)[bitrateIndex]);
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.ENGLISH,
|
||||
"%s://%s:%d/audio/external_id/%s%s",
|
||||
protocol,
|
||||
prefs.getString(Prefs.Key.ADDRESS, Prefs.Default.ADDRESS),
|
||||
prefs.getInt(Prefs.Key.AUDIO_PORT, Prefs.Default.AUDIO_PORT),
|
||||
URLEncoder.encode(externalId),
|
||||
bitrateQueryParam);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolvePrevIndex(final int currentIndex, final int count) {
|
||||
if (currentIndex - 1 < 0) {
|
||||
if (repeatMode == RepeatMode.List) {
|
||||
return count - 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return currentIndex - 1;
|
||||
}
|
||||
|
||||
private int resolveNextIndex(final int currentIndex, final int count, boolean userInitiated) {
|
||||
if (shuffled) { /* our shuffle is actually random for now. */
|
||||
if (count == 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
int r = random.nextInt(count - 1);
|
||||
while (r == currentIndex) {
|
||||
r = random.nextInt(count - 1);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
else if (!userInitiated && repeatMode == RepeatMode.Track) {
|
||||
return currentIndex;
|
||||
}
|
||||
else {
|
||||
if (currentIndex + 1 >= count) {
|
||||
if (repeatMode == RepeatMode.List) {
|
||||
return 0;
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SocketMessage getMetadataQuery(int index) {
|
||||
return queryFactory.getRequeryMessage()
|
||||
.buildUpon()
|
||||
.removeOption(Messages.Key.COUNT_ONLY)
|
||||
.addOption(Messages.Key.LIMIT, 1)
|
||||
.addOption(Messages.Key.OFFSET, index)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Observable<SocketMessage> getCurrentAndNextTrackMessages(final PlaybackContext context, final int queueCount) {
|
||||
final List<Observable<SocketMessage>> tracks = new ArrayList<>();
|
||||
|
||||
if (queueCount > 0) {
|
||||
if (trackMetadataCache.containsKey(context.currentIndex)) {
|
||||
context.currentMetadata = trackMetadataCache.get(context.currentIndex);
|
||||
}
|
||||
else {
|
||||
tracks.add(wss.send(getMetadataQuery(context.currentIndex), wssClient));
|
||||
}
|
||||
|
||||
if (queueCount > 1) { /* let's prefetch the next track as well */
|
||||
context.nextIndex = resolveNextIndex(context.currentIndex, queueCount, false);
|
||||
|
||||
if (context.nextIndex >= 0) {
|
||||
if (trackMetadataCache.containsKey(context.nextIndex)) {
|
||||
context.nextMetadata = trackMetadataCache.get(context.nextIndex);
|
||||
}
|
||||
else {
|
||||
tracks.add(wss.send(getMetadataQuery(context.nextIndex), wssClient));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.concat(tracks);
|
||||
}
|
||||
|
||||
private static Observable<Integer> getQueueCount(final PlaybackContext context, final SocketMessage message) {
|
||||
context.queueCount = message.getIntOption(Messages.Key.COUNT, 0);
|
||||
return Observable.just(context.queueCount);
|
||||
}
|
||||
|
||||
private static JSONObject extractTrackFromMessage(final SocketMessage message) {
|
||||
final JSONArray data = message.getJsonArrayOption(Messages.Key.DATA);
|
||||
if (data.length() > 0) {
|
||||
return data.optJSONObject(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void prefetchNextTrackAudio() {
|
||||
if (this.context.nextMetadata != null) {
|
||||
final String uri = getUri(this.context.nextMetadata);
|
||||
|
||||
if (uri != null) {
|
||||
this.context.reset(this.context.nextPlayer);
|
||||
this.context.nextPlayer = PlayerWrapper.Companion.newInstance();
|
||||
this.context.nextPlayer.setOnStateChangedListener(onNextPlayerStateChanged);
|
||||
this.context.nextPlayer.prefetch(uri, this.context.nextMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void invalidateAndPrefetchNextTrackMetadata() {
|
||||
if (context.queueCount > 0) {
|
||||
if (context.nextMetadata != null) {
|
||||
context.reset(context.nextPlayer);
|
||||
context.nextMetadata = null;
|
||||
context.nextPlayer = null;
|
||||
context.nextIndex = -1;
|
||||
|
||||
if (context.currentPlayer != null) {
|
||||
context.currentPlayer.setNextMediaPlayer(null);
|
||||
}
|
||||
}
|
||||
|
||||
prefetchNextTrackMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
private void prefetchNextTrackMetadata() {
|
||||
if (context.nextMetadata == null) {
|
||||
final QueueParams params = this.params;
|
||||
|
||||
final int nextIndex = resolveNextIndex(context.currentIndex, context.queueCount, false);
|
||||
|
||||
if (trackMetadataCache.containsKey(nextIndex)) {
|
||||
context.nextMetadata = trackMetadataCache.get(nextIndex);
|
||||
context.nextIndex = nextIndex;
|
||||
prefetchNextTrackAudio();
|
||||
}
|
||||
else if (nextIndex >= 0) {
|
||||
final int currentIndex = context.currentIndex;
|
||||
this.wss.send(getMetadataQuery(nextIndex), this.wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.map(StreamingPlaybackService::extractTrackFromMessage)
|
||||
.subscribe(
|
||||
(track) -> {
|
||||
if (params == StreamingPlaybackService.this.params && context.currentIndex == currentIndex) {
|
||||
if (context.nextMetadata == null) {
|
||||
context.nextIndex = nextIndex;
|
||||
context.nextMetadata = track;
|
||||
prefetchNextTrackAudio();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
(error) -> {
|
||||
Log.e(TAG, "failed to prefetch next track!", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void loadQueueAndPlay(final QueueParams params, int startIndex) {
|
||||
setState(PlaybackState.Buffering);
|
||||
|
||||
cancelScheduledPausedSleep();
|
||||
SystemService.wakeup();
|
||||
|
||||
this.pausedByTransientLoss = false;
|
||||
this.context.stopPlaybackAndReset();
|
||||
final PlaybackContext context = new PlaybackContext();
|
||||
this.context = context;
|
||||
context.currentIndex = startIndex;
|
||||
|
||||
this.params = params;
|
||||
final SocketMessage countMessage = queryFactory.getRequeryMessage();
|
||||
|
||||
this.wss.send(countMessage, this.wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.flatMap(response -> getQueueCount(context, response))
|
||||
.concatMap(count -> getCurrentAndNextTrackMessages(context, count))
|
||||
.map(StreamingPlaybackService::extractTrackFromMessage)
|
||||
.subscribe(
|
||||
track -> {
|
||||
if (context.currentMetadata == null) {
|
||||
context.currentMetadata = track;
|
||||
}
|
||||
else {
|
||||
context.nextMetadata = track;
|
||||
}
|
||||
},
|
||||
|
||||
error -> {
|
||||
Log.e(TAG, "failed to load track to play!", error);
|
||||
setState(PlaybackState.Stopped);
|
||||
},
|
||||
|
||||
() -> {
|
||||
if (this.params == params && this.context == context) {
|
||||
notifyEventListeners();
|
||||
|
||||
final String uri = getUri(this.context.currentMetadata);
|
||||
|
||||
if (uri != null) {
|
||||
this.context.currentPlayer = PlayerWrapper.Companion.newInstance();
|
||||
this.context.currentPlayer.setOnStateChangedListener(onCurrentPlayerStateChanged);
|
||||
this.context.currentPlayer.play(uri, this.context.currentMetadata);
|
||||
}
|
||||
}
|
||||
else {
|
||||
Log.d(TAG, "onComplete fired, but params/context changed. discarding!");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void cancelScheduledPausedSleep() {
|
||||
SystemService.wakeup();
|
||||
handler.removeCallbacks(pauseServiceSleepRunnable);
|
||||
}
|
||||
|
||||
private void schedulePausedSleep() {
|
||||
handler.postDelayed(pauseServiceSleepRunnable, PAUSED_SERVICE_SLEEP_DELAY_MS);
|
||||
}
|
||||
|
||||
private void precacheTrackMetadata(final int start, final int count) {
|
||||
final QueueParams params = this.params;
|
||||
final SocketMessage query = queryFactory.getPageAroundMessage(start, count);
|
||||
|
||||
this.wss.send(query, this.wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
response -> {
|
||||
if (params == this.params) {
|
||||
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
|
||||
for (int i = 0; i < data.length(); i++) {
|
||||
trackMetadataCache.put(start + i, data.getJSONObject(i));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
error -> {
|
||||
Log.e(TAG, "failed to prefetch track metadata!", error);
|
||||
});
|
||||
}
|
||||
|
||||
private TrackListSlidingWindow.QueryFactory queryFactory = new TrackListSlidingWindow.QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
if (params != null) {
|
||||
if (Strings.notEmpty(params.category) && params.categoryId >= 0) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, params.category)
|
||||
.addOption(Messages.Key.ID, params.categoryId)
|
||||
.addOption(Messages.Key.FILTER, params.filter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, params.filter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
if (params != null) {
|
||||
if (Strings.notEmpty(params.category) && params.categoryId >= 0) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, params.category)
|
||||
.addOption(Messages.Key.ID, params.categoryId)
|
||||
.addOption(Messages.Key.FILTER, params.filter)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, params.filter)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean connectionRequired() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client wssClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private Runnable pauseServiceSleepRunnable = () -> SystemService.sleep();
|
||||
|
||||
private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = (flag) -> {
|
||||
switch (flag) {
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
|
||||
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
|
||||
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
|
||||
PlayerWrapper.Companion.unduck();
|
||||
if (pausedByTransientLoss) {
|
||||
pausedByTransientLoss = false;
|
||||
resume();
|
||||
}
|
||||
break;
|
||||
|
||||
case AudioManager.AUDIOFOCUS_LOSS:
|
||||
killAudioFocus();
|
||||
pause();
|
||||
break;
|
||||
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||
switch (getPlaybackState()) {
|
||||
case Playing:
|
||||
case Buffering:
|
||||
pauseTransient();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||
PlayerWrapper.Companion.duck();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private class SettingsContentObserver extends ContentObserver {
|
||||
public SettingsContentObserver() {
|
||||
super(new Handler(Looper.getMainLooper()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deliverSelfNotifications() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
super.onChange(selfChange);
|
||||
|
||||
int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
|
||||
if (currentVolume != lastSystemVolume) {
|
||||
lastSystemVolume = currentVolume;
|
||||
notifyEventListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,897 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.database.ContentObserver
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
class StreamingPlaybackService(context: Context) : PlaybackService {
|
||||
private val wss: WebSocketService = WebSocketService.getInstance(context.applicationContext)
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
private val listeners = HashSet<() -> Unit>()
|
||||
private var params: QueueParams? = null
|
||||
private var playContext = PlaybackContext()
|
||||
private var audioManager: AudioManager? = null
|
||||
private var lastSystemVolume: Int = 0
|
||||
private var pausedByTransientLoss = false
|
||||
private val random = Random()
|
||||
private val handler = Handler()
|
||||
|
||||
private val trackMetadataCache = object : LinkedHashMap<Int, JSONObject>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, JSONObject>): Boolean {
|
||||
return size >= MAX_TRACK_METADATA_CACHE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private class PlaybackContext {
|
||||
internal var queueCount: Int = 0
|
||||
internal var currentPlayer: PlayerWrapper? = null
|
||||
internal var nextPlayer: PlayerWrapper? = null
|
||||
internal var currentMetadata: JSONObject? = null
|
||||
internal var nextMetadata: JSONObject? = null
|
||||
internal var currentIndex = -1
|
||||
internal var nextIndex = -1
|
||||
internal var nextPlayerScheduled: Boolean = false
|
||||
|
||||
fun stopPlaybackAndReset() {
|
||||
reset(currentPlayer)
|
||||
reset(nextPlayer)
|
||||
nextPlayerScheduled = false
|
||||
nextPlayer = null
|
||||
currentPlayer = null
|
||||
nextMetadata = null
|
||||
currentMetadata = null
|
||||
nextIndex = -1
|
||||
currentIndex = -1
|
||||
}
|
||||
|
||||
fun notifyNextTrackPrepared() {
|
||||
if (currentPlayer != null && nextPlayer != null) {
|
||||
currentPlayer?.setNextMediaPlayer(nextPlayer)
|
||||
nextPlayerScheduled = true
|
||||
}
|
||||
}
|
||||
|
||||
fun advanceToNextTrack(currentTrackListener: (PlayerWrapper, PlayerWrapper.State) -> Unit): Boolean {
|
||||
var startedNext = false
|
||||
|
||||
if (nextMetadata != null && nextPlayer != null) {
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer?.setOnStateChangedListener(null)
|
||||
currentPlayer?.dispose()
|
||||
}
|
||||
|
||||
currentMetadata = nextMetadata
|
||||
currentIndex = nextIndex
|
||||
currentPlayer = nextPlayer
|
||||
startedNext = true
|
||||
}
|
||||
else {
|
||||
reset(currentPlayer)
|
||||
currentPlayer = null
|
||||
currentMetadata = null
|
||||
currentIndex = 0
|
||||
}
|
||||
|
||||
nextPlayer = null
|
||||
nextMetadata = null
|
||||
nextIndex = -1
|
||||
nextPlayerScheduled = false
|
||||
|
||||
/* needs to be done after swapping current/next, otherwise event handlers
|
||||
will fire, and things may get cleaned up before we have a chance to start */
|
||||
if (startedNext) {
|
||||
currentPlayer?.setOnStateChangedListener(currentTrackListener)
|
||||
currentPlayer?.resume() /* no-op if playing already */
|
||||
}
|
||||
|
||||
return startedNext
|
||||
}
|
||||
|
||||
fun reset(wrapper: PlayerWrapper?) {
|
||||
if (wrapper != null) {
|
||||
wrapper.setOnStateChangedListener(null)
|
||||
wrapper.dispose()
|
||||
|
||||
if (wrapper === nextPlayer) {
|
||||
nextPlayerScheduled = false /* uhh... */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class QueueParams {
|
||||
internal val category: String?
|
||||
internal val categoryId: Long
|
||||
internal val filter: String
|
||||
|
||||
constructor(filter: String) {
|
||||
this.filter = filter
|
||||
this.categoryId = -1
|
||||
this.category = null
|
||||
}
|
||||
|
||||
constructor(category: String, categoryId: Long, filter: String) {
|
||||
this.category = category
|
||||
this.categoryId = categoryId
|
||||
this.filter = filter
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized override fun connect(listener: () -> Unit) {
|
||||
listeners.add(listener)
|
||||
if (listeners.size == 1) {
|
||||
wss.addClient(wssClient)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized override fun disconnect(listener: () -> Unit) {
|
||||
listeners.remove(listener)
|
||||
if (listeners.size == 0) {
|
||||
wss.removeClient(wssClient)
|
||||
}
|
||||
}
|
||||
|
||||
override fun playAll() {
|
||||
playAll(0, "")
|
||||
}
|
||||
|
||||
override fun playAll(index: Int, filter: String) {
|
||||
if (requestAudioFocus()) {
|
||||
trackMetadataCache.clear()
|
||||
loadQueueAndPlay(QueueParams(filter), index)
|
||||
}
|
||||
}
|
||||
|
||||
override fun play(category: String, categoryId: Long, index: Int, filter: String) {
|
||||
if (requestAudioFocus()) {
|
||||
trackMetadataCache.clear()
|
||||
loadQueueAndPlay(QueueParams(category, categoryId, filter), index)
|
||||
}
|
||||
}
|
||||
|
||||
override fun playAt(index: Int) {
|
||||
if (params != null) {
|
||||
if (requestAudioFocus()) {
|
||||
playContext.stopPlaybackAndReset()
|
||||
loadQueueAndPlay(params!!, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pauseOrResume() {
|
||||
if (playContext.currentPlayer != null) {
|
||||
if (playbackState === PlaybackState.Playing || playbackState === PlaybackState.Buffering) {
|
||||
pause()
|
||||
}
|
||||
else {
|
||||
resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (playbackState != PlaybackState.Paused) {
|
||||
schedulePausedSleep()
|
||||
killAudioFocus()
|
||||
|
||||
if (playContext.currentPlayer != null) {
|
||||
playContext.currentPlayer?.pause()
|
||||
setState(PlaybackState.Paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep()
|
||||
pausedByTransientLoss = false
|
||||
|
||||
if (playContext.currentPlayer != null) {
|
||||
playContext.currentPlayer?.resume()
|
||||
setState(PlaybackState.Playing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
SystemService.shutdown()
|
||||
killAudioFocus()
|
||||
playContext.stopPlaybackAndReset()
|
||||
trackMetadataCache.clear()
|
||||
setState(PlaybackState.Stopped)
|
||||
}
|
||||
|
||||
override fun prev() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep()
|
||||
|
||||
if (playContext.currentPlayer != null) {
|
||||
if (playContext.currentPlayer?.position ?: 0 > PREV_TRACK_GRACE_PERIOD_MILLIS) {
|
||||
playContext.currentPlayer?.position = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
moveToPrevTrack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun next() {
|
||||
if (requestAudioFocus()) {
|
||||
cancelScheduledPausedSleep()
|
||||
moveToNextTrack(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun volumeUp() {
|
||||
adjustVolume(volumeStep)
|
||||
}
|
||||
|
||||
override fun volumeDown() {
|
||||
adjustVolume(-volumeStep)
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
if (requestAudioFocus()) {
|
||||
if (playContext.currentPlayer != null) {
|
||||
val pos = playContext.currentPlayer?.position ?: 0
|
||||
playContext.currentPlayer?.position = pos + 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekBackward() {
|
||||
if (requestAudioFocus()) {
|
||||
if (playContext.currentPlayer != null) {
|
||||
val pos = playContext.currentPlayer?.position ?: 0
|
||||
playContext.currentPlayer?.position = pos - 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(seconds: Double) {
|
||||
if (requestAudioFocus()) {
|
||||
playContext.currentPlayer?.position = (seconds * 1000).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
override val queueCount: Int
|
||||
get() = playContext.queueCount
|
||||
|
||||
override val queuePosition: Int
|
||||
get() = playContext.currentIndex
|
||||
|
||||
override val volume: Double
|
||||
get() {
|
||||
if (prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME)) {
|
||||
return PlayerWrapper.getVolume().toDouble()
|
||||
}
|
||||
|
||||
return systemVolume.toDouble()
|
||||
}
|
||||
|
||||
override val duration: Double
|
||||
get() {
|
||||
return (playContext.currentPlayer?.duration?.toDouble() ?: 0.0) / 1000.0
|
||||
}
|
||||
|
||||
override val currentTime: Double
|
||||
get() {
|
||||
return (playContext.currentPlayer?.position?.toDouble() ?: 0.0) / 1000.0
|
||||
}
|
||||
|
||||
override var isShuffled: Boolean = false
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var isMuted: Boolean = false
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var repeatMode = RepeatMode.None
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override var playbackState = PlaybackState.Stopped
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
override fun toggleShuffle() {
|
||||
isShuffled = !isShuffled
|
||||
invalidateAndPrefetchNextTrackMetadata()
|
||||
notifyEventListeners()
|
||||
}
|
||||
|
||||
override fun toggleMute() {
|
||||
isMuted = !isMuted
|
||||
PlayerWrapper.setMute(isMuted)
|
||||
notifyEventListeners()
|
||||
}
|
||||
|
||||
override fun toggleRepeatMode() {
|
||||
when (repeatMode) {
|
||||
RepeatMode.None -> repeatMode = RepeatMode.List
|
||||
RepeatMode.List -> repeatMode = RepeatMode.Track
|
||||
else -> repeatMode = RepeatMode.None
|
||||
}
|
||||
|
||||
this.prefs.edit().putString(REPEAT_MODE_PREF, repeatMode.toString()).apply()
|
||||
invalidateAndPrefetchNextTrackMetadata()
|
||||
notifyEventListeners()
|
||||
}
|
||||
|
||||
override fun getTrackString(key: String, defaultValue: String): String {
|
||||
return playContext.currentMetadata?.optString(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun getTrackLong(key: String, defaultValue: Long): Long {
|
||||
return playContext.currentMetadata?.optLong(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override val bufferedTime: Double /* ms -> sec */
|
||||
get() {
|
||||
if (playContext.currentPlayer != null) {
|
||||
val percent = playContext.currentPlayer!!.bufferedPercent.toFloat() / 100.0f
|
||||
return (percent * playContext.currentPlayer!!.duration.toFloat() / 1000.0f).toDouble()
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun pauseTransient() {
|
||||
if (playbackState !== PlaybackState.Paused) {
|
||||
pausedByTransientLoss = true
|
||||
setState(PlaybackState.Paused)
|
||||
playContext.currentPlayer?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private val volumeStep: Float
|
||||
get() {
|
||||
if (prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME)) {
|
||||
return 0.1f
|
||||
}
|
||||
return 1.0f / maxSystemVolume
|
||||
}
|
||||
|
||||
private fun adjustVolume(delta: Float) {
|
||||
if (isMuted) {
|
||||
toggleMute()
|
||||
}
|
||||
|
||||
val softwareVolume = prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME)
|
||||
var current = if (softwareVolume) PlayerWrapper.getVolume() else systemVolume
|
||||
|
||||
current += delta
|
||||
if (current > 1.0) current = 1.0f
|
||||
if (current < 0.0) current = 0.0f
|
||||
|
||||
if (softwareVolume) {
|
||||
PlayerWrapper.setVolume(current)
|
||||
}
|
||||
else {
|
||||
val actual = Math.round(current * maxSystemVolume)
|
||||
lastSystemVolume = actual
|
||||
audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, actual, 0)
|
||||
}
|
||||
|
||||
notifyEventListeners()
|
||||
}
|
||||
|
||||
private val systemVolume: Float
|
||||
get() {
|
||||
val current = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC)?.toFloat() ?: 0.0f
|
||||
return current / maxSystemVolume
|
||||
}
|
||||
|
||||
private val maxSystemVolume: Float
|
||||
get() = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC)?.toFloat() ?: 0.0f
|
||||
|
||||
private fun killAudioFocus() {
|
||||
audioManager?.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
return audioManager?.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
private fun moveToPrevTrack() {
|
||||
if (playContext.queueCount > 0) {
|
||||
loadQueueAndPlay(params!!, resolvePrevIndex(
|
||||
playContext.currentIndex, playContext.queueCount))
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveToNextTrack(userInitiated: Boolean) {
|
||||
val index = playContext.currentIndex
|
||||
if (!userInitiated && playContext.advanceToNextTrack(onCurrentPlayerStateChanged)) {
|
||||
notifyEventListeners()
|
||||
prefetchNextTrackMetadata()
|
||||
}
|
||||
else {
|
||||
/* failed! reset by loading as if the user selected the next track
|
||||
manually (this will automatically load the current and next tracks */
|
||||
val next = resolveNextIndex(index, playContext.queueCount, userInitiated)
|
||||
if (next >= 0) {
|
||||
loadQueueAndPlay(params!!, next)
|
||||
}
|
||||
else {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val onCurrentPlayerStateChanged = { _: PlayerWrapper, state: PlayerWrapper.State ->
|
||||
when (state) {
|
||||
PlayerWrapper.State.Playing -> {
|
||||
setState(PlaybackState.Playing)
|
||||
prefetchNextTrackAudio()
|
||||
cancelScheduledPausedSleep()
|
||||
precacheTrackMetadata(playContext.currentIndex, PRECACHE_METADATA_SIZE)
|
||||
}
|
||||
|
||||
PlayerWrapper.State.Buffering -> setState(PlaybackState.Buffering)
|
||||
|
||||
PlayerWrapper.State.Paused -> pause()
|
||||
|
||||
PlayerWrapper.State.Error -> pause()
|
||||
|
||||
PlayerWrapper.State.Finished -> if (playbackState !== PlaybackState.Paused) {
|
||||
moveToNextTrack(false)
|
||||
}
|
||||
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
private val onNextPlayerStateChanged = { mpw: PlayerWrapper, state: PlayerWrapper.State ->
|
||||
if (state === PlayerWrapper.State.Prepared) {
|
||||
if (mpw === playContext.nextPlayer) {
|
||||
playContext.notifyNextTrackPrepared()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setState(state: PlaybackState) {
|
||||
if (playbackState !== state) {
|
||||
Log.d(TAG, "state = " + state)
|
||||
playbackState = state
|
||||
notifyEventListeners()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized private fun notifyEventListeners() {
|
||||
for (listener in listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUri(track: JSONObject?): String? {
|
||||
if (track != null) {
|
||||
val existingUri = track.optString(Metadata.Track.URI, "")
|
||||
if (Strings.notEmpty(existingUri)) {
|
||||
return existingUri
|
||||
}
|
||||
|
||||
val externalId = track.optString(Metadata.Track.EXTERNAL_ID, "")
|
||||
if (Strings.notEmpty(externalId)) {
|
||||
val ssl = prefs.getBoolean(Prefs.Key.SSL_ENABLED, Prefs.Default.SSL_ENABLED)
|
||||
val protocol = if (ssl) "https" else "http"
|
||||
|
||||
/* transcoding bitrate, if selected by the user */
|
||||
var bitrateQueryParam = ""
|
||||
val bitrateIndex = prefs.getInt(
|
||||
Prefs.Key.TRANSCODER_BITRATE_INDEX,
|
||||
Prefs.Default.TRANSCODER_BITRATE_INDEX)
|
||||
|
||||
if (bitrateIndex > 0) {
|
||||
val r = Application.instance!!.resources
|
||||
|
||||
bitrateQueryParam = String.format(
|
||||
Locale.ENGLISH,
|
||||
"?bitrate=%s",
|
||||
r.getStringArray(R.array.transcode_bitrate_array)[bitrateIndex])
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.ENGLISH,
|
||||
"%s://%s:%d/audio/external_id/%s%s",
|
||||
protocol,
|
||||
prefs.getString(Prefs.Key.ADDRESS, Prefs.Default.ADDRESS),
|
||||
prefs.getInt(Prefs.Key.AUDIO_PORT, Prefs.Default.AUDIO_PORT),
|
||||
URLEncoder.encode(externalId),
|
||||
bitrateQueryParam)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolvePrevIndex(currentIndex: Int, count: Int): Int {
|
||||
if (currentIndex - 1 < 0) {
|
||||
if (repeatMode === RepeatMode.List) {
|
||||
return count - 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return currentIndex - 1
|
||||
}
|
||||
|
||||
private fun resolveNextIndex(currentIndex: Int, count: Int, userInitiated: Boolean): Int {
|
||||
if (isShuffled) { /* our shuffle matches actually random for now. */
|
||||
if (count == 0) {
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
var r = random.nextInt(count - 1)
|
||||
while (r == currentIndex) {
|
||||
r = random.nextInt(count - 1)
|
||||
}
|
||||
return r
|
||||
}
|
||||
else if (!userInitiated && repeatMode === RepeatMode.Track) {
|
||||
return currentIndex
|
||||
}
|
||||
else {
|
||||
if (currentIndex + 1 >= count) {
|
||||
if (repeatMode === RepeatMode.List) {
|
||||
return 0
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
else {
|
||||
return currentIndex + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMetadataQuery(index: Int): SocketMessage {
|
||||
return playlistQueryFactory.getRequeryMessage()!!
|
||||
.buildUpon()
|
||||
.removeOption(Messages.Key.COUNT_ONLY)
|
||||
.addOption(Messages.Key.LIMIT, 1)
|
||||
.addOption(Messages.Key.OFFSET, index)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getCurrentAndNextTrackMessages(context: PlaybackContext, queueCount: Int): Observable<SocketMessage> {
|
||||
val tracks = ArrayList<Observable<SocketMessage>>()
|
||||
|
||||
if (queueCount > 0) {
|
||||
if (trackMetadataCache.containsKey(context.currentIndex)) {
|
||||
context.currentMetadata = trackMetadataCache[context.currentIndex]
|
||||
}
|
||||
else {
|
||||
tracks.add(wss.sendObserve(getMetadataQuery(context.currentIndex), wssClient))
|
||||
}
|
||||
|
||||
if (queueCount > 1) { /* let's prefetch the next track as well */
|
||||
context.nextIndex = resolveNextIndex(context.currentIndex, queueCount, false)
|
||||
|
||||
if (context.nextIndex >= 0) {
|
||||
if (trackMetadataCache.containsKey(context.nextIndex)) {
|
||||
context.nextMetadata = trackMetadataCache[context.nextIndex]
|
||||
}
|
||||
else {
|
||||
tracks.add(wss.sendObserve(getMetadataQuery(context.nextIndex), wssClient))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.concat(tracks)
|
||||
}
|
||||
|
||||
private fun prefetchNextTrackAudio() {
|
||||
if (playContext.nextMetadata != null) {
|
||||
val uri = getUri(playContext.nextMetadata)
|
||||
|
||||
if (uri != null) {
|
||||
playContext.reset(playContext.nextPlayer)
|
||||
playContext.nextPlayer = PlayerWrapper.newInstance()
|
||||
playContext.nextPlayer?.setOnStateChangedListener(onNextPlayerStateChanged)
|
||||
playContext.nextPlayer?.prefetch(uri, playContext.nextMetadata!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateAndPrefetchNextTrackMetadata() {
|
||||
if (playContext.queueCount > 0) {
|
||||
if (playContext.nextMetadata != null) {
|
||||
playContext.reset(playContext.nextPlayer)
|
||||
playContext.nextMetadata = null
|
||||
playContext.nextPlayer = null
|
||||
playContext.nextIndex = -1
|
||||
playContext.currentPlayer?.setNextMediaPlayer(null)
|
||||
}
|
||||
|
||||
prefetchNextTrackMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefetchNextTrackMetadata() {
|
||||
if (playContext.nextMetadata == null) {
|
||||
val params = this.params
|
||||
|
||||
val nextIndex = resolveNextIndex(playContext.currentIndex, playContext.queueCount, false)
|
||||
|
||||
if (trackMetadataCache.containsKey(nextIndex)) {
|
||||
playContext.nextMetadata = trackMetadataCache[nextIndex]
|
||||
playContext.nextIndex = nextIndex
|
||||
prefetchNextTrackAudio()
|
||||
}
|
||||
else if (nextIndex >= 0) {
|
||||
val currentIndex = playContext.currentIndex
|
||||
|
||||
this.wss.sendObserve(getMetadataQuery(nextIndex), this.wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.map { message -> extractTrackFromMessage(message) }
|
||||
.subscribe(
|
||||
{ track ->
|
||||
if (params === this@StreamingPlaybackService.params && playContext.currentIndex == currentIndex) {
|
||||
if (playContext.nextMetadata == null) {
|
||||
playContext.nextIndex = nextIndex
|
||||
playContext.nextMetadata = track
|
||||
prefetchNextTrackAudio()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
Log.e(TAG, "failed to prefetch next track!", error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadQueueAndPlay(newParams: QueueParams, startIndex: Int) {
|
||||
setState(PlaybackState.Buffering)
|
||||
|
||||
cancelScheduledPausedSleep()
|
||||
SystemService.wakeup()
|
||||
|
||||
pausedByTransientLoss = false
|
||||
|
||||
val newPlayContext = PlaybackContext()
|
||||
playContext.stopPlaybackAndReset()
|
||||
playContext = newPlayContext
|
||||
playContext.currentIndex = startIndex
|
||||
|
||||
params = newParams
|
||||
|
||||
val countMessage = playlistQueryFactory.getRequeryMessage() ?: return
|
||||
|
||||
wss.sendObserve(countMessage, wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.flatMap { response -> getQueueCount(playContext, response) }
|
||||
.concatMap { count -> getCurrentAndNextTrackMessages(playContext, count ?: 0) }
|
||||
.map { message -> extractTrackFromMessage(message) }
|
||||
.subscribe(
|
||||
{ track ->
|
||||
if (playContext.currentMetadata == null) {
|
||||
playContext.currentMetadata = track
|
||||
}
|
||||
else {
|
||||
playContext.nextMetadata = track
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
Log.e(TAG, "failed to load track to play!", error)
|
||||
setState(PlaybackState.Stopped)
|
||||
},
|
||||
{
|
||||
if (this.params === newParams && playContext === newPlayContext) {
|
||||
notifyEventListeners()
|
||||
|
||||
val uri = getUri(playContext.currentMetadata)
|
||||
|
||||
if (uri != null) {
|
||||
playContext.currentPlayer = PlayerWrapper.newInstance()
|
||||
playContext.currentPlayer?.setOnStateChangedListener(onCurrentPlayerStateChanged)
|
||||
playContext.currentPlayer?.play(uri, playContext.currentMetadata!!)
|
||||
}
|
||||
}
|
||||
else {
|
||||
Log.d(TAG, "onComplete fired, but params/context changed. discarding!")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun cancelScheduledPausedSleep() {
|
||||
SystemService.wakeup()
|
||||
handler.removeCallbacks(pauseServiceSleepRunnable)
|
||||
}
|
||||
|
||||
private fun schedulePausedSleep() {
|
||||
handler.postDelayed(pauseServiceSleepRunnable, PAUSED_SERVICE_SLEEP_DELAY_MS.toLong())
|
||||
}
|
||||
|
||||
private fun precacheTrackMetadata(start: Int, count: Int) {
|
||||
val originalParams = params
|
||||
val query = playlistQueryFactory.getPageAroundMessage(start, count)
|
||||
|
||||
if (query != null) {
|
||||
this.wss.sendObserve(query, this.wssClient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ response ->
|
||||
if (originalParams === this.params) {
|
||||
val data = response.getJsonArrayOption(Messages.Key.DATA) ?: JSONArray()
|
||||
for (i in 0..data.length() - 1) {
|
||||
trackMetadataCache.put(start + i, data.getJSONObject(i))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
error -> Log.e(TAG, "failed to prefetch track metadata!", error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() {
|
||||
override fun getRequeryMessage(): SocketMessage {
|
||||
if (params != null) {
|
||||
if (Strings.notEmpty(params?.category) && (params?.categoryId ?: -1) >= 0) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, params?.category)
|
||||
.addOption(Messages.Key.ID, params?.categoryId)
|
||||
.addOption(Messages.Key.FILTER, params?.filter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build()
|
||||
}
|
||||
else {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, params?.filter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPageAroundMessage(offset: Int, limit: Int): SocketMessage? {
|
||||
if (params != null) {
|
||||
if (Strings.notEmpty(params?.category) && (params?.categoryId ?: -1) >= 0) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, params?.category)
|
||||
.addOption(Messages.Key.ID, params?.categoryId)
|
||||
.addOption(Messages.Key.FILTER, params?.filter)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.build()
|
||||
}
|
||||
else {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, params?.filter)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun connectionRequired(): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val wssClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {}
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
init {
|
||||
this.audioManager = Application.instance?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
this.lastSystemVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0
|
||||
this.repeatMode = RepeatMode.from(this.prefs.getString(REPEAT_MODE_PREF, RepeatMode.None.toString())!!)
|
||||
this.audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, SettingsContentObserver())
|
||||
}
|
||||
|
||||
private val pauseServiceSleepRunnable = object: Runnable {
|
||||
override fun run() {
|
||||
SystemService.sleep()
|
||||
}
|
||||
}
|
||||
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { flag: Int ->
|
||||
when (flag) {
|
||||
AudioManager.AUDIOFOCUS_GAIN,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> {
|
||||
PlayerWrapper.unduck()
|
||||
if (pausedByTransientLoss) {
|
||||
pausedByTransientLoss = false
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
killAudioFocus()
|
||||
pause()
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> when (playbackState) {
|
||||
PlaybackState.Playing,
|
||||
PlaybackState.Buffering -> pauseTransient()
|
||||
else -> { }
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> PlayerWrapper.duck()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SettingsContentObserver : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||
override fun deliverSelfNotifications(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
|
||||
val currentVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0
|
||||
if (currentVolume != lastSystemVolume) {
|
||||
lastSystemVolume = currentVolume
|
||||
notifyEventListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StreamingPlayback"
|
||||
private val REPEAT_MODE_PREF = "streaming_playback_repeat_mode"
|
||||
private val PREV_TRACK_GRACE_PERIOD_MILLIS = 3500
|
||||
private val MAX_TRACK_METADATA_CACHE_SIZE = 50
|
||||
private val PRECACHE_METADATA_SIZE = 10
|
||||
private val PAUSED_SERVICE_SLEEP_DELAY_MS = 1000 * 60 * 5 /* 5 minutes */
|
||||
|
||||
private fun getQueueCount(context: PlaybackContext, message: SocketMessage): Observable<Int> {
|
||||
context.queueCount = message.getIntOption(Messages.Key.COUNT, 0)
|
||||
return Observable.just(context.queueCount)
|
||||
}
|
||||
|
||||
private fun extractTrackFromMessage(message: SocketMessage): JSONObject? {
|
||||
val data = message.getJsonArrayOption(Messages.Key.DATA) ?: JSONArray()
|
||||
return if (data.length() > 0) data.optJSONObject(0) else null
|
||||
}
|
||||
}
|
||||
}
|
@ -1,537 +0,0 @@
|
||||
package io.casey.musikcube.remote.playback;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.media.MediaMetadataCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.support.v7.app.NotificationCompat;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.animation.GlideAnimation;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
|
||||
import io.casey.musikcube.remote.Application;
|
||||
import io.casey.musikcube.remote.MainActivity;
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.ui.model.AlbumArtModel;
|
||||
import io.casey.musikcube.remote.util.Debouncer;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
|
||||
/* basically a stub service that exists to keep a connection active to the
|
||||
StreamingPlaybackService, which keeps music playing. TODO: should also hold
|
||||
a partial wakelock to keep the radio from going to sleep. */
|
||||
public class SystemService extends Service {
|
||||
private static final String TAG = "SystemService";
|
||||
private static final int NOTIFICATION_ID = 0xdeadbeef;
|
||||
private static final int HEADSET_HOOK_DEBOUNCE_MS = 500;
|
||||
private static final String ACTION_NOTIFICATION_PLAY = "io.casey.musikcube.remote.NOTIFICATION_PLAY";
|
||||
private static final String ACTION_NOTIFICATION_PAUSE = "io.casey.musikcube.remote.NOTIFICATION_PAUSE";
|
||||
private static final String ACTION_NOTIFICATION_NEXT = "io.casey.musikcube.remote.NOTIFICATION_NEXT";
|
||||
private static final String ACTION_NOTIFICATION_PREV = "io.casey.musikcube.remote.NOTIFICATION_PREV";
|
||||
public static final String ACTION_NOTIFICATION_STOP = "io.casey.musikcube.remote.PAUSE_SHUT_DOWN";
|
||||
public static String ACTION_WAKE_UP = "io.casey.musikcube.remote.WAKE_UP";
|
||||
public static String ACTION_SHUT_DOWN = "io.casey.musikcube.remote.SHUT_DOWN";
|
||||
public static String ACTION_SLEEP = "io.casey.musikcube.remote.SLEEP";
|
||||
|
||||
private final static long MEDIA_SESSION_ACTIONS =
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE |
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT |
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD |
|
||||
PlaybackStateCompat.ACTION_REWIND;
|
||||
|
||||
private Handler handler = new Handler();
|
||||
private SharedPreferences prefs;
|
||||
private StreamingPlaybackService playback;
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
private PowerManager powerManager;
|
||||
private MediaSessionCompat mediaSession;
|
||||
private int headsetHookPressCount = 0;
|
||||
|
||||
private AlbumArtModel albumArtModel = AlbumArtModel.empty();
|
||||
private Bitmap albumArt = null;
|
||||
private SimpleTarget<Bitmap> albumArtRequest;
|
||||
private Runnable delayedSleep;
|
||||
|
||||
public static void wakeup() {
|
||||
final Context c = Application.Companion.getInstance();
|
||||
c.startService(new Intent(c, SystemService.class).setAction(ACTION_WAKE_UP));
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
final Context c = Application.Companion.getInstance();
|
||||
c.startService(new Intent(c, SystemService.class).setAction(ACTION_SHUT_DOWN));
|
||||
}
|
||||
|
||||
public static void sleep() {
|
||||
final Context c = Application.Companion.getInstance();
|
||||
c.startService(new Intent(c, SystemService.class).setAction(ACTION_SLEEP));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
prefs = this.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
powerManager = (PowerManager) getSystemService(POWER_SERVICE);
|
||||
registerReceivers();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
recycleAlbumArt();
|
||||
unregisterReceivers();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null) {
|
||||
final String action = intent.getAction();
|
||||
if (ACTION_WAKE_UP.equals(action)) {
|
||||
wakeupNow();
|
||||
}
|
||||
else if (ACTION_SHUT_DOWN.equals(action)) {
|
||||
shutdownNow();
|
||||
}
|
||||
else if (ACTION_SLEEP.equals(action)) {
|
||||
sleepNow();
|
||||
}
|
||||
else if (handlePlaybackAction(action)) {
|
||||
wakeupNow();
|
||||
return super.onStartCommand(intent, flags, startId);
|
||||
}
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void wakeupNow() {
|
||||
Log.d(TAG, "SystemService WAKE_UP");
|
||||
|
||||
final boolean sleeping = (playback == null || wakeLock == null);
|
||||
|
||||
if (playback == null) {
|
||||
playback = PlaybackServiceFactory.streaming(this);
|
||||
}
|
||||
|
||||
if (wakeLock == null) {
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "StreamingPlaybackService");
|
||||
|
||||
wakeLock.setReferenceCounted(false);
|
||||
wakeLock.acquire();
|
||||
}
|
||||
|
||||
if (sleeping) {
|
||||
playback.connect(listener);
|
||||
initMediaSession();
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdownNow() {
|
||||
Log.d(TAG, "SystemService SHUT_DOWN");
|
||||
|
||||
if (mediaSession != null) {
|
||||
mediaSession.release();
|
||||
}
|
||||
|
||||
if (playback != null) {
|
||||
playback.disconnect(listener);
|
||||
playback = null;
|
||||
}
|
||||
|
||||
if (wakeLock != null) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private void sleepNow() {
|
||||
Log.d(TAG, "SystemService SLEEP");
|
||||
|
||||
if (wakeLock != null) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
if (playback != null) {
|
||||
playback.disconnect(listener);
|
||||
}
|
||||
}
|
||||
|
||||
private void initMediaSession() {
|
||||
ComponentName receiver = new ComponentName(getPackageName(), MediaButtonReceiver.class.getName());
|
||||
|
||||
mediaSession = new MediaSessionCompat(this, "musikdroid.SystemService", receiver, null);
|
||||
|
||||
mediaSession.setFlags(
|
||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
|
||||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
|
||||
|
||||
mediaSession.setCallback(mediaSessionCallback);
|
||||
|
||||
updateMediaSessionPlaybackState();
|
||||
mediaSession.setActive(true);
|
||||
}
|
||||
|
||||
private void registerReceivers() {
|
||||
final IntentFilter filter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
|
||||
registerReceiver(headsetUnpluggedReceiver, filter);
|
||||
}
|
||||
|
||||
private void unregisterReceivers() {
|
||||
try {
|
||||
unregisterReceiver(headsetUnpluggedReceiver);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Log.e(TAG, "unable to unregister headset (un)plugged BroadcastReceiver");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateMediaSessionPlaybackState() {
|
||||
int mediaSessionState = PlaybackStateCompat.STATE_STOPPED;
|
||||
|
||||
String title = "-";
|
||||
String album = "-";
|
||||
String artist = "-";
|
||||
int duration = 0;
|
||||
|
||||
if (playback != null) {
|
||||
switch (playback.getPlaybackState()) {
|
||||
case Playing:
|
||||
mediaSessionState = PlaybackStateCompat.STATE_PLAYING;
|
||||
break;
|
||||
case Buffering:
|
||||
mediaSessionState = PlaybackStateCompat.STATE_BUFFERING;
|
||||
break;
|
||||
case Paused:
|
||||
mediaSessionState = PlaybackStateCompat.STATE_PAUSED;
|
||||
break;
|
||||
}
|
||||
|
||||
title = playback.getTrackString(Metadata.Track.TITLE, "-");
|
||||
album = playback.getTrackString(Metadata.Track.ALBUM, "-");
|
||||
artist = playback.getTrackString(Metadata.Track.ARTIST, "-");
|
||||
duration = (int) (playback.getDuration() * 1000);
|
||||
}
|
||||
|
||||
updateMetadata(title, artist, album, null, duration);
|
||||
updateNotification(title, artist, album, mediaSessionState);
|
||||
|
||||
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder()
|
||||
.setState(mediaSessionState, 0, 0)
|
||||
.setActions(MEDIA_SESSION_ACTIONS)
|
||||
.build());
|
||||
}
|
||||
|
||||
private synchronized void recycleAlbumArt() {
|
||||
if (albumArt != null) {
|
||||
//albumArt.recycle();
|
||||
albumArt = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadAlbumArt(final String title, final String artist, final String album, final int duration) {
|
||||
recycleAlbumArt();
|
||||
|
||||
albumArtModel = new AlbumArtModel(title, artist, album, AlbumArtModel.Size.Mega, (info, url) -> {
|
||||
if (albumArtModel.is(artist, album)) {
|
||||
handler.post(() -> {
|
||||
if (albumArtRequest != null && albumArtRequest.getRequest() != null) {
|
||||
albumArtRequest.getRequest().clear();
|
||||
}
|
||||
|
||||
albumArtRequest = Glide
|
||||
.with(getApplicationContext())
|
||||
.load(url)
|
||||
.asBitmap()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(new SimpleTarget<Bitmap>(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) {
|
||||
@Override
|
||||
public void onResourceReady(final Bitmap bitmap, GlideAnimation glideAnimation) {
|
||||
albumArtRequest = null;
|
||||
if (albumArtModel.is(artist, album)) {
|
||||
albumArt = bitmap;
|
||||
updateMetadata(title, artist, album, bitmap, duration);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
albumArtModel.fetch();
|
||||
}
|
||||
|
||||
private void updateMetadata(final String title, final String artist, final String album, Bitmap image, final int duration) {
|
||||
boolean albumArtEnabledInSettings = this.prefs.getBoolean(
|
||||
Prefs.Key.ALBUM_ART_ENABLED, Prefs.Default.ALBUM_ART_ENABLED);
|
||||
|
||||
if (albumArtEnabledInSettings) {
|
||||
if (!"-".equals(artist) && !"-".equals(album) && !albumArtModel.is(artist, album)) {
|
||||
downloadAlbumArt(title, artist, album, duration);
|
||||
}
|
||||
else if (albumArtModel.is(artist, album)) {
|
||||
if (image == null && Strings.notEmpty(albumArtModel.getUrl())) {
|
||||
/* lookup may have failed -- try again. if the fetch is already in
|
||||
progress this will be a no-op */
|
||||
albumArtModel.fetch();
|
||||
}
|
||||
|
||||
image = albumArt;
|
||||
}
|
||||
else {
|
||||
recycleAlbumArt();
|
||||
}
|
||||
}
|
||||
|
||||
mediaSession.setMetadata(new MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, image)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void updateNotification(final String title, final String artist, final String album, final int state) {
|
||||
final PendingIntent contentIntent = PendingIntent.getActivity(
|
||||
getApplicationContext(), 1, MainActivity.getStartIntent(this), 0);
|
||||
|
||||
android.support.v4.app.NotificationCompat.Builder notification = new NotificationCompat.Builder(this)
|
||||
.setSmallIcon(R.mipmap.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist + " - " + album)
|
||||
.setContentIntent(contentIntent)
|
||||
.setUsesChronometer(false)
|
||||
//.setLargeIcon(albumArtBitmap))
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true);
|
||||
|
||||
if (state == PlaybackStateCompat.STATE_STOPPED) {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_play,
|
||||
getString(R.string.button_play),
|
||||
ACTION_NOTIFICATION_PLAY));
|
||||
|
||||
notification.setStyle(new NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0)
|
||||
.setMediaSession(mediaSession.getSessionToken()));
|
||||
}
|
||||
else {
|
||||
if (state == PlaybackStateCompat.STATE_PAUSED) {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_play,
|
||||
getString(R.string.button_play),
|
||||
ACTION_NOTIFICATION_PLAY));
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
getString(R.string.button_close),
|
||||
ACTION_NOTIFICATION_STOP));
|
||||
|
||||
notification.setStyle(new NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1)
|
||||
.setMediaSession(mediaSession.getSessionToken()));
|
||||
}
|
||||
else {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_previous,
|
||||
getString(R.string.button_prev),
|
||||
ACTION_NOTIFICATION_PREV));
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_pause,
|
||||
getString(R.string.button_pause),
|
||||
ACTION_NOTIFICATION_PAUSE));
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_next,
|
||||
getString(R.string.button_next),
|
||||
ACTION_NOTIFICATION_NEXT));
|
||||
|
||||
notification.setStyle(new NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
.setMediaSession(mediaSession.getSessionToken()));
|
||||
}
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification.build());
|
||||
}
|
||||
|
||||
private NotificationCompat.Action action(int icon, String title, String intentAction) {
|
||||
Intent intent = new Intent(getApplicationContext(), SystemService.class);
|
||||
intent.setAction(intentAction);
|
||||
PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0);
|
||||
return new NotificationCompat.Action.Builder(icon, title, pendingIntent).build();
|
||||
}
|
||||
|
||||
private boolean handlePlaybackAction(final String action) {
|
||||
if (this.playback != null && Strings.notEmpty(action)) {
|
||||
switch (action) {
|
||||
case ACTION_NOTIFICATION_NEXT:
|
||||
this.playback.next();
|
||||
return true;
|
||||
|
||||
case ACTION_NOTIFICATION_PAUSE:
|
||||
this.playback.pause();
|
||||
break;
|
||||
|
||||
case ACTION_NOTIFICATION_PLAY:
|
||||
this.playback.resume();
|
||||
return true;
|
||||
|
||||
case ACTION_NOTIFICATION_PREV:
|
||||
this.playback.prev();
|
||||
return true;
|
||||
|
||||
case ACTION_NOTIFICATION_STOP:
|
||||
this.playback.stop();
|
||||
SystemService.shutdown();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Debouncer<Void> headsetHookDebouncer = new Debouncer<Void>(HEADSET_HOOK_DEBOUNCE_MS) {
|
||||
@Override
|
||||
protected void onDebounced(Void caller) {
|
||||
if (headsetHookPressCount == 1) {
|
||||
playback.pauseOrResume();
|
||||
}
|
||||
else if (headsetHookPressCount == 2) {
|
||||
playback.next();
|
||||
}
|
||||
else if (headsetHookPressCount > 2) {
|
||||
playback.prev();
|
||||
}
|
||||
headsetHookPressCount = 0;
|
||||
}
|
||||
};
|
||||
|
||||
private MediaSessionCompat.Callback mediaSessionCallback = new MediaSessionCompat.Callback() {
|
||||
@Override
|
||||
public boolean onMediaButtonEvent(Intent mediaButtonEvent) {
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(mediaButtonEvent.getAction())) {
|
||||
final KeyEvent event = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
||||
if (event == null) {
|
||||
return super.onMediaButtonEvent(mediaButtonEvent);
|
||||
}
|
||||
|
||||
final int keycode = event.getKeyCode();
|
||||
final int action = event.getAction();
|
||||
if (event.getRepeatCount() == 0 && action == KeyEvent.ACTION_DOWN) {
|
||||
switch (keycode) {
|
||||
case KeyEvent.KEYCODE_HEADSETHOOK:
|
||||
++headsetHookPressCount;
|
||||
headsetHookDebouncer.call();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_STOP:
|
||||
playback.pause();
|
||||
SystemService.shutdown();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
|
||||
playback.pauseOrResume();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_NEXT:
|
||||
playback.next();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
|
||||
playback.prev();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_PAUSE:
|
||||
playback.pause();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MEDIA_PLAY:
|
||||
playback.resume();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onMediaButtonEvent(mediaButtonEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlay() {
|
||||
super.onPlay();
|
||||
if (playback.getQueueCount() == 0) {
|
||||
playback.playAll();
|
||||
}
|
||||
else {
|
||||
playback.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
playback.pause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSkipToNext() {
|
||||
super.onSkipToNext();
|
||||
playback.next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSkipToPrevious() {
|
||||
super.onSkipToPrevious();
|
||||
playback.prev();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFastForward() {
|
||||
super.onFastForward();
|
||||
playback.seekForward();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRewind() {
|
||||
super.onRewind();
|
||||
playback.seekBackward();
|
||||
}
|
||||
};
|
||||
|
||||
private PlaybackService.EventListener listener = () -> {
|
||||
updateMediaSessionPlaybackState();
|
||||
};
|
||||
|
||||
private BroadcastReceiver headsetUnpluggedReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent.getAction().equals(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) {
|
||||
if (playback != null) {
|
||||
playback.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,524 @@
|
||||
package io.casey.musikcube.remote.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.support.v4.app.NotificationCompat.Action as NotifAction
|
||||
import android.support.v7.app.NotificationCompat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.animation.GlideAnimation
|
||||
import com.bumptech.glide.request.target.SimpleTarget
|
||||
import com.bumptech.glide.request.target.Target
|
||||
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.MainActivity
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.ui.model.AlbumArtModel
|
||||
import io.casey.musikcube.remote.util.Debouncer
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
|
||||
/* basically a stub service that exists to keep a connection active to the
|
||||
StreamingPlaybackService, which keeps music playing. TODO: should also hold
|
||||
a partial wakelock to keep the radio from going to sleep. */
|
||||
class SystemService : Service() {
|
||||
private val handler = Handler()
|
||||
private var prefs: SharedPreferences? = null
|
||||
private var playback: StreamingPlaybackService? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var powerManager: PowerManager? = null
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var headsetHookPressCount = 0
|
||||
|
||||
private var albumArtModel = AlbumArtModel.empty()
|
||||
private var albumArt: Bitmap? = null
|
||||
private var albumArtRequest: SimpleTarget<Bitmap>? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
prefs = getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
registerReceivers()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
recycleAlbumArt()
|
||||
unregisterReceivers()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
ACTION_WAKE_UP -> wakeupNow()
|
||||
ACTION_SHUT_DOWN -> shutdownNow()
|
||||
ACTION_SLEEP -> sleepNow()
|
||||
else -> {
|
||||
if (handlePlaybackAction(intent.action)) {
|
||||
wakeupNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun wakeupNow() {
|
||||
Log.d(TAG, "SystemService WAKE_UP")
|
||||
|
||||
val sleeping = playback == null || wakeLock == null
|
||||
|
||||
if (playback == null) {
|
||||
playback = PlaybackServiceFactory.streaming(this)
|
||||
}
|
||||
|
||||
if (wakeLock == null) {
|
||||
wakeLock = powerManager?.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "StreamingPlaybackService")
|
||||
|
||||
wakeLock?.setReferenceCounted(false)
|
||||
wakeLock?.acquire()
|
||||
}
|
||||
|
||||
if (sleeping) {
|
||||
playback?.connect(playbackListener)
|
||||
initMediaSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdownNow() {
|
||||
Log.d(TAG, "SystemService SHUT_DOWN")
|
||||
|
||||
if (mediaSession != null) {
|
||||
mediaSession?.release()
|
||||
}
|
||||
|
||||
if (playback != null) {
|
||||
playback?.disconnect(playbackListener)
|
||||
playback = null
|
||||
}
|
||||
|
||||
if (wakeLock != null) {
|
||||
wakeLock?.release()
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun sleepNow() {
|
||||
Log.d(TAG, "SystemService SLEEP")
|
||||
|
||||
if (wakeLock != null) {
|
||||
wakeLock?.release()
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
if (playback != null) {
|
||||
playback?.disconnect(playbackListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initMediaSession() {
|
||||
val receiver = ComponentName(packageName, MediaButtonReceiver::class.java.name)
|
||||
|
||||
mediaSession = MediaSessionCompat(this, "musikdroid.SystemService", receiver, null)
|
||||
|
||||
mediaSession?.setFlags(
|
||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
|
||||
mediaSession?.setCallback(mediaSessionCallback)
|
||||
|
||||
updateMediaSessionPlaybackState()
|
||||
|
||||
mediaSession?.isActive = true
|
||||
}
|
||||
|
||||
private fun registerReceivers() {
|
||||
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
registerReceiver(headsetUnpluggedReceiver, filter)
|
||||
}
|
||||
|
||||
private fun unregisterReceivers() {
|
||||
try {
|
||||
unregisterReceiver(headsetUnpluggedReceiver)
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
Log.e(TAG, "unable to unregister headset (un)plugged BroadcastReceiver")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMediaSessionPlaybackState() {
|
||||
var mediaSessionState = PlaybackStateCompat.STATE_STOPPED
|
||||
|
||||
var title = "-"
|
||||
var album = "-"
|
||||
var artist = "-"
|
||||
var duration = 0
|
||||
|
||||
if (playback != null) {
|
||||
when (playback?.playbackState) {
|
||||
PlaybackState.Playing -> mediaSessionState = PlaybackStateCompat.STATE_PLAYING
|
||||
PlaybackState.Buffering -> mediaSessionState = PlaybackStateCompat.STATE_BUFFERING
|
||||
PlaybackState.Paused -> mediaSessionState = PlaybackStateCompat.STATE_PAUSED
|
||||
else -> { }
|
||||
}
|
||||
|
||||
title = playback?.getTrackString(Metadata.Track.TITLE, "-")!!
|
||||
album = playback?.getTrackString(Metadata.Track.ALBUM, "-")!!
|
||||
artist = playback?.getTrackString(Metadata.Track.ARTIST, "-")!!
|
||||
duration = ((playback?.duration ?: 0.0) * 1000).toInt()
|
||||
}
|
||||
|
||||
updateMetadata(title, artist, album, null, duration)
|
||||
updateNotification(title, artist, album, mediaSessionState)
|
||||
|
||||
mediaSession?.setPlaybackState(PlaybackStateCompat.Builder()
|
||||
.setState(mediaSessionState, 0, 0f)
|
||||
.setActions(MEDIA_SESSION_ACTIONS)
|
||||
.build())
|
||||
}
|
||||
|
||||
@Synchronized private fun recycleAlbumArt() {
|
||||
if (albumArt != null) {
|
||||
albumArt = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAlbumArt(title: String, artist: String, album: String, duration: Int) {
|
||||
recycleAlbumArt()
|
||||
|
||||
albumArtModel = AlbumArtModel(title, artist, album, AlbumArtModel.Size.Mega, object: AlbumArtModel.AlbumArtCallback {
|
||||
override fun onFinished(model: AlbumArtModel, url: String?) {
|
||||
if (albumArtModel.matches(artist, album)) {
|
||||
handler.post {
|
||||
if (albumArtRequest != null && albumArtRequest?.request != null) {
|
||||
albumArtRequest?.request?.clear()
|
||||
}
|
||||
|
||||
albumArtRequest = Glide
|
||||
.with(applicationContext)
|
||||
.load(url)
|
||||
.asBitmap()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(object: SimpleTarget<Bitmap>(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) {
|
||||
override fun onResourceReady(bitmap: Bitmap?, glideAnimation: GlideAnimation<in Bitmap>?) {
|
||||
albumArtRequest = null
|
||||
if (albumArtModel.matches(artist, album)) {
|
||||
albumArt = bitmap
|
||||
updateMetadata(title, artist, album, bitmap, duration)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
albumArtModel.fetch()
|
||||
}
|
||||
|
||||
private fun updateMetadata(title: String, artist: String, album: String, image: Bitmap?, duration: Int) {
|
||||
var currentImage = image
|
||||
|
||||
val albumArtEnabledInSettings = this.prefs?.getBoolean(
|
||||
Prefs.Key.ALBUM_ART_ENABLED, Prefs.Default.ALBUM_ART_ENABLED) ?: Prefs.Default.ALBUM_ART_ENABLED
|
||||
|
||||
if (albumArtEnabledInSettings) {
|
||||
if ("-" != artist && "-" != album && !albumArtModel.matches(artist, album)) {
|
||||
downloadAlbumArt(title, artist, album, duration)
|
||||
}
|
||||
else if (albumArtModel.matches(artist, album)) {
|
||||
if (currentImage == null && Strings.notEmpty(albumArtModel.url)) {
|
||||
/* lookup may have failed -- try again. if the fetch matches already in
|
||||
progress this will be a no-op */
|
||||
albumArtModel.fetch()
|
||||
}
|
||||
|
||||
currentImage = albumArt
|
||||
}
|
||||
else {
|
||||
recycleAlbumArt()
|
||||
}
|
||||
}
|
||||
|
||||
mediaSession?.setMetadata(MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong())
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentImage)
|
||||
.build())
|
||||
}
|
||||
|
||||
private fun updateNotification(title: String, artist: String, album: String, state: Int) {
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
applicationContext, 1, MainActivity.getStartIntent(this), 0)
|
||||
|
||||
val notification = NotificationCompat.Builder(this)
|
||||
.setSmallIcon(R.mipmap.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist + " - " + album)
|
||||
.setContentIntent(contentIntent)
|
||||
.setUsesChronometer(false)
|
||||
//.setLargeIcon(albumArtBitmap))
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
|
||||
if (state == PlaybackStateCompat.STATE_STOPPED) {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_play,
|
||||
getString(R.string.button_play),
|
||||
ACTION_NOTIFICATION_PLAY))
|
||||
|
||||
notification.setStyle(NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0)
|
||||
.setMediaSession(mediaSession?.sessionToken))
|
||||
}
|
||||
else {
|
||||
if (state == PlaybackStateCompat.STATE_PAUSED) {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_play,
|
||||
getString(R.string.button_play),
|
||||
ACTION_NOTIFICATION_PLAY))
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
getString(R.string.button_close),
|
||||
ACTION_NOTIFICATION_STOP))
|
||||
|
||||
notification.setStyle(NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1)
|
||||
.setMediaSession(mediaSession?.sessionToken))
|
||||
}
|
||||
else {
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_previous,
|
||||
getString(R.string.button_prev),
|
||||
ACTION_NOTIFICATION_PREV))
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_pause,
|
||||
getString(R.string.button_pause),
|
||||
ACTION_NOTIFICATION_PAUSE))
|
||||
|
||||
notification.addAction(action(
|
||||
android.R.drawable.ic_media_next,
|
||||
getString(R.string.button_next),
|
||||
ACTION_NOTIFICATION_NEXT))
|
||||
|
||||
notification.setStyle(NotificationCompat.MediaStyle()
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
.setMediaSession(mediaSession?.sessionToken))
|
||||
}
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification.build())
|
||||
}
|
||||
|
||||
private fun action(icon: Int, title: String, intentAction: String): NotifAction {
|
||||
val intent = Intent(applicationContext, SystemService::class.java)
|
||||
intent.action = intentAction
|
||||
val pendingIntent = PendingIntent.getService(applicationContext, 1, intent, 0)
|
||||
return NotifAction.Builder(icon, title, pendingIntent).build()
|
||||
}
|
||||
|
||||
private fun handlePlaybackAction(action: String): Boolean {
|
||||
if (this.playback != null && Strings.notEmpty(action)) {
|
||||
when (action) {
|
||||
ACTION_NOTIFICATION_NEXT -> {
|
||||
this.playback?.next()
|
||||
return true
|
||||
}
|
||||
|
||||
ACTION_NOTIFICATION_PAUSE -> {
|
||||
this.playback?.pause()
|
||||
return true
|
||||
}
|
||||
|
||||
ACTION_NOTIFICATION_PLAY -> {
|
||||
this.playback?.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
ACTION_NOTIFICATION_PREV -> {
|
||||
this.playback?.prev()
|
||||
return true
|
||||
}
|
||||
|
||||
ACTION_NOTIFICATION_STOP -> {
|
||||
this.playback?.stop()
|
||||
SystemService.shutdown()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private val headsetHookDebouncer = object : Debouncer<Void>(HEADSET_HOOK_DEBOUNCE_MS) {
|
||||
override fun onDebounced(last: Void?) {
|
||||
if (headsetHookPressCount == 1) {
|
||||
playback?.pauseOrResume()
|
||||
}
|
||||
else if (headsetHookPressCount == 2) {
|
||||
playback?.next()
|
||||
}
|
||||
else if (headsetHookPressCount > 2) {
|
||||
playback?.prev()
|
||||
}
|
||||
headsetHookPressCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
|
||||
if (Intent.ACTION_MEDIA_BUTTON == mediaButtonEvent?.action) {
|
||||
val event = mediaButtonEvent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return super.onMediaButtonEvent(mediaButtonEvent)
|
||||
|
||||
val keycode = event.keyCode
|
||||
val action = event.action
|
||||
if (event.repeatCount == 0 && action == KeyEvent.ACTION_DOWN) {
|
||||
when (keycode) {
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||
++headsetHookPressCount
|
||||
headsetHookDebouncer.call()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playback?.pause()
|
||||
SystemService.shutdown()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
playback?.pauseOrResume()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
playback?.next()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playback?.prev()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
playback?.pause()
|
||||
return true
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
playback?.resume()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onMediaButtonEvent(mediaButtonEvent)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
super.onPlay()
|
||||
if (playback?.queueCount == 0) {
|
||||
playback?.playAll()
|
||||
}
|
||||
else {
|
||||
playback?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
playback?.pause()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
playback?.next()
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious()
|
||||
playback?.prev()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
super.onFastForward()
|
||||
playback?.seekForward()
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
super.onRewind()
|
||||
playback?.seekBackward()
|
||||
}
|
||||
}
|
||||
|
||||
private val playbackListener = {
|
||||
updateMediaSessionPlaybackState()
|
||||
}
|
||||
|
||||
private val headsetUnpluggedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
|
||||
playback?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "SystemService"
|
||||
private val NOTIFICATION_ID = 0xdeadbeef.toInt()
|
||||
private val HEADSET_HOOK_DEBOUNCE_MS = 500L
|
||||
private val ACTION_NOTIFICATION_PLAY = "io.casey.musikcube.remote.NOTIFICATION_PLAY"
|
||||
private val ACTION_NOTIFICATION_PAUSE = "io.casey.musikcube.remote.NOTIFICATION_PAUSE"
|
||||
private val ACTION_NOTIFICATION_NEXT = "io.casey.musikcube.remote.NOTIFICATION_NEXT"
|
||||
private val ACTION_NOTIFICATION_PREV = "io.casey.musikcube.remote.NOTIFICATION_PREV"
|
||||
val ACTION_NOTIFICATION_STOP = "io.casey.musikcube.remote.PAUSE_SHUT_DOWN"
|
||||
var ACTION_WAKE_UP = "io.casey.musikcube.remote.WAKE_UP"
|
||||
var ACTION_SHUT_DOWN = "io.casey.musikcube.remote.SHUT_DOWN"
|
||||
var ACTION_SLEEP = "io.casey.musikcube.remote.SLEEP"
|
||||
|
||||
private val MEDIA_SESSION_ACTIONS =
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_REWIND
|
||||
|
||||
fun wakeup() {
|
||||
val c = Application.instance
|
||||
c?.startService(Intent(c, SystemService::class.java).setAction(ACTION_WAKE_UP))
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
val c = Application.instance
|
||||
c?.startService(Intent(c, SystemService::class.java).setAction(ACTION_SHUT_DOWN))
|
||||
}
|
||||
|
||||
fun sleep() {
|
||||
val c = Application.instance
|
||||
c?.startService(Intent(c, SystemService::class.java).setAction(ACTION_SLEEP))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,255 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView;
|
||||
import io.casey.musikcube.remote.util.Debouncer;
|
||||
import io.casey.musikcube.remote.util.Navigation;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
import static io.casey.musikcube.remote.websocket.Messages.Key;
|
||||
|
||||
public class AlbumBrowseActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static final String EXTRA_CATEGORY_NAME = "extra_category_name";
|
||||
private static final String EXTRA_CATEGORY_ID = "extra_category_id";
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, AlbumBrowseActivity.class);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String categoryName, long categoryId) {
|
||||
return new Intent(context, AlbumBrowseActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY_NAME, categoryName)
|
||||
.putExtra(EXTRA_CATEGORY_ID, categoryId);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context,
|
||||
final String categoryName,
|
||||
long categoryId,
|
||||
final String categoryValue)
|
||||
{
|
||||
final Intent intent = getStartIntent(context, categoryName, categoryId);
|
||||
|
||||
if (Strings.notEmpty(categoryValue)) {
|
||||
intent.putExtra(
|
||||
Views.EXTRA_TITLE,
|
||||
context.getString(R.string.albums_by_title, categoryValue));
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context,
|
||||
final String categoryName,
|
||||
final JSONObject categoryJson)
|
||||
{
|
||||
final String value = categoryJson.optString(Messages.Key.VALUE);
|
||||
final long categoryId = categoryJson.optLong(Messages.Key.ID);
|
||||
return getStartIntent(context, categoryName, categoryId, value);
|
||||
}
|
||||
|
||||
private WebSocketService wss;
|
||||
private Adapter adapter;
|
||||
private TransportFragment transport;
|
||||
private String categoryName;
|
||||
private long categoryId;
|
||||
private String lastFilter = "";
|
||||
private EmptyListView emptyView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.categoryName = getIntent().getStringExtra(EXTRA_CATEGORY_NAME);
|
||||
this.categoryId = getIntent().getLongExtra(EXTRA_CATEGORY_ID, categoryId);
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
Views.setTitle(this, R.string.albums_title);
|
||||
Views.enableUpNavigation(this);
|
||||
|
||||
this.wss = getWebSocketService();
|
||||
this.adapter = new Adapter();
|
||||
|
||||
final RecyclerFastScroller fastScroller = (RecyclerFastScroller) findViewById(R.id.fast_scroller);
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter);
|
||||
|
||||
emptyView = (EmptyListView) findViewById(R.id.empty_list_view);
|
||||
emptyView.setCapability(EmptyListView.Capability.OnlineOnly);
|
||||
emptyView.setEmptyMessage(getString(R.string.empty_no_items_format, getString(R.string.browse_type_albums)));
|
||||
emptyView.setAlternateView(recyclerView);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.lastFilter = filter;
|
||||
filterDebouncer.call(filter);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
private void requery() {
|
||||
final SocketMessage message =
|
||||
SocketMessage.Builder
|
||||
.request(Messages.Request.QueryAlbums)
|
||||
.addOption(Messages.Key.CATEGORY, categoryName)
|
||||
.addOption(Messages.Key.CATEGORY_ID, categoryId)
|
||||
.addOption(Key.FILTER, lastFilter)
|
||||
.build();
|
||||
|
||||
wss.send(message, socketClient, (SocketMessage response) -> {
|
||||
adapter.setModel(response.getJsonArrayOption(Messages.Key.DATA));
|
||||
emptyView.update(wss.getState(), adapter.getItemCount());
|
||||
});
|
||||
}
|
||||
|
||||
private final Debouncer<String> filterDebouncer = new Debouncer<String>(350) {
|
||||
@Override
|
||||
protected void onDebounced(String context) {
|
||||
if (!isPaused()) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client socketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
filterDebouncer.call();
|
||||
requery();
|
||||
}
|
||||
else {
|
||||
emptyView.update(newState, adapter.getItemCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
final JSONObject album = (JSONObject) view.getTag();
|
||||
final long id = album.optLong(Key.ID);
|
||||
final String title = album.optString(Metadata.Album.TITLE, "");
|
||||
|
||||
final Intent intent = TrackListActivity.getStartIntent(
|
||||
AlbumBrowseActivity.this, Messages.Category.ALBUM, id, title);
|
||||
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_TRACKS_ACTIVITY);
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry) {
|
||||
long playingId = transport.getPlaybackService().getTrackLong(Metadata.Track.ALBUM_ID, -1);
|
||||
long entryId = entry.optLong(Key.ID);
|
||||
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (playingId != -1 && entryId == playingId) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Metadata.Album.TITLE, "-"));
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
|
||||
subtitle.setText(entry.optString(Metadata.Album.ALBUM_ARTIST, "-"));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
|
||||
itemView.setTag(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
private JSONArray model;
|
||||
|
||||
void setModel(JSONArray model) {
|
||||
this.model = model;
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(model.optJSONObject(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (model == null) ? 0 : model.length();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView
|
||||
import io.casey.musikcube.remote.util.Debouncer
|
||||
import io.casey.musikcube.remote.util.Navigation
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
import io.casey.musikcube.remote.websocket.Messages.Key
|
||||
|
||||
class AlbumBrowseActivity : WebSocketActivityBase(), Filterable {
|
||||
private var adapter: Adapter = Adapter()
|
||||
private var transport: TransportFragment? = null
|
||||
private var categoryName: String? = null
|
||||
private var categoryId: Long = 0
|
||||
private var lastFilter = ""
|
||||
private var emptyView: EmptyListView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
categoryName = intent.getStringExtra(EXTRA_CATEGORY_NAME)
|
||||
categoryId = intent.getLongExtra(EXTRA_CATEGORY_ID, categoryId)
|
||||
|
||||
setContentView(R.layout.recycler_view_activity)
|
||||
|
||||
setTitleFromIntent(R.string.albums_title)
|
||||
enableUpNavigation()
|
||||
|
||||
val fastScroller = findViewById(R.id.fast_scroller) as RecyclerFastScroller
|
||||
val recyclerView = findViewById(R.id.recycler_view) as RecyclerView
|
||||
setupDefaultRecyclerView(recyclerView, fastScroller, adapter)
|
||||
|
||||
emptyView = findViewById(R.id.empty_list_view) as EmptyListView
|
||||
emptyView?.capability = EmptyListView.Capability.OnlineOnly
|
||||
emptyView?.emptyMessage = getString(R.string.empty_no_items_format, getString(R.string.browse_type_albums))
|
||||
emptyView?.alternateView = recyclerView
|
||||
|
||||
transport = addTransportFragment(object: TransportFragment.OnModelChangedListener {
|
||||
override fun onChanged(fragment: TransportFragment) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
initSearchMenu(menu, this)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setFilter(filter: String) {
|
||||
lastFilter = filter
|
||||
filterDebouncer.call(filter)
|
||||
}
|
||||
|
||||
override val webSocketServiceClient: WebSocketService.Client?
|
||||
get() = socketClient
|
||||
|
||||
override val playbackServiceEventListener: (() -> Unit)?
|
||||
get() = null
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED)
|
||||
finish()
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun requery() {
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryAlbums)
|
||||
.addOption(Messages.Key.CATEGORY, categoryName)
|
||||
.addOption(Messages.Key.CATEGORY_ID, categoryId)
|
||||
.addOption(Key.FILTER, lastFilter)
|
||||
.build()
|
||||
|
||||
getWebSocketService().send(message, socketClient) { response: SocketMessage ->
|
||||
adapter.setModel(response.getJsonArrayOption(Messages.Key.DATA) ?: JSONArray())
|
||||
emptyView?.update(getWebSocketService().state, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
private val filterDebouncer = object : Debouncer<String>(350) {
|
||||
override fun onDebounced(last: String?) {
|
||||
if (!isPaused) {
|
||||
requery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val socketClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState === WebSocketService.State.Connected) {
|
||||
filterDebouncer.call()
|
||||
requery()
|
||||
}
|
||||
else {
|
||||
emptyView!!.update(newState, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
private val onItemClickListener = { view: View ->
|
||||
val album = view.tag as JSONObject
|
||||
val id = album.optLong(Key.ID)
|
||||
val title = album.optString(Metadata.Album.TITLE, "")
|
||||
|
||||
val intent = TrackListActivity.getStartIntent(
|
||||
this@AlbumBrowseActivity, Messages.Category.ALBUM, id, title)
|
||||
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_TRACKS_ACTIVITY)
|
||||
}
|
||||
|
||||
private inner class ViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val title: TextView = itemView.findViewById(R.id.title) as TextView
|
||||
private val subtitle: TextView = itemView.findViewById(R.id.subtitle) as TextView
|
||||
|
||||
internal fun bind(entry: JSONObject) {
|
||||
val playingId = transport?.playbackService?.getTrackLong(Metadata.Track.ALBUM_ID, -1L) ?: -1L
|
||||
val entryId = entry.optLong(Key.ID)
|
||||
|
||||
var titleColor = R.color.theme_foreground
|
||||
var subtitleColor = R.color.theme_disabled_foreground
|
||||
|
||||
if (playingId != -1L && entryId == playingId) {
|
||||
titleColor = R.color.theme_green
|
||||
subtitleColor = R.color.theme_yellow
|
||||
}
|
||||
|
||||
title.text = entry.optString(Metadata.Album.TITLE, "-")
|
||||
title.setTextColor(resources.getColor(titleColor))
|
||||
|
||||
subtitle.text = entry.optString(Metadata.Album.ALBUM_ARTIST, "-")
|
||||
subtitle.setTextColor(resources.getColor(subtitleColor))
|
||||
|
||||
itemView.tag = entry
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Adapter : RecyclerView.Adapter<ViewHolder>() {
|
||||
private var model: JSONArray = JSONArray()
|
||||
|
||||
internal fun setModel(model: JSONArray?) {
|
||||
this.model = model ?: JSONArray()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.simple_list_item, parent, false)
|
||||
view.setOnClickListener(onItemClickListener)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(model.optJSONObject(position))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return model.length()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXTRA_CATEGORY_NAME = "extra_category_name"
|
||||
private val EXTRA_CATEGORY_ID = "extra_category_id"
|
||||
|
||||
fun getStartIntent(context: Context): Intent {
|
||||
return Intent(context, AlbumBrowseActivity::class.java)
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context, categoryName: String, categoryId: Long): Intent {
|
||||
return Intent(context, AlbumBrowseActivity::class.java)
|
||||
.putExtra(EXTRA_CATEGORY_NAME, categoryName)
|
||||
.putExtra(EXTRA_CATEGORY_ID, categoryId)
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context, categoryName: String, categoryId: Long, categoryValue: String): Intent {
|
||||
val intent = getStartIntent(context, categoryName, categoryId)
|
||||
|
||||
if (Strings.notEmpty(categoryValue)) {
|
||||
intent.putExtra(
|
||||
EXTRA_ACTIVITY_TITLE,
|
||||
context.getString(R.string.albums_by_title, categoryValue))
|
||||
}
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context, categoryName: String, categoryJson: JSONObject): Intent {
|
||||
val value = categoryJson.optString(Messages.Key.VALUE)
|
||||
val categoryId = categoryJson.optLong(Messages.Key.ID)
|
||||
return getStartIntent(context, categoryName, categoryId, value)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,292 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView;
|
||||
import io.casey.musikcube.remote.util.Debouncer;
|
||||
import io.casey.musikcube.remote.util.Navigation;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class CategoryBrowseActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static final String EXTRA_CATEGORY = "extra_category";
|
||||
private static final String EXTRA_DEEP_LINK_TYPE = "extra_deep_link_type";
|
||||
|
||||
public interface DeepLink {
|
||||
int TRACKS = 0;
|
||||
int ALBUMS = 1;
|
||||
}
|
||||
|
||||
private static final Map<String, String> CATEGORY_NAME_TO_ID = new HashMap<>();
|
||||
private static final Map<String, Integer> CATEGORY_NAME_TO_TITLE = new HashMap<>();
|
||||
private static final Map<String, Integer> CATEGORY_NAME_TO_EMPTY_TYPE = new HashMap<>();
|
||||
|
||||
static {
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Category.ALBUM_ARTIST, Metadata.Track.ALBUM_ARTIST_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Category.GENRE, Metadata.Track.GENRE_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Category.ARTIST, Metadata.Track.ARTIST_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Category.ALBUM, Metadata.Track.ALBUM_ID);
|
||||
CATEGORY_NAME_TO_ID.put(Messages.Category.PLAYLISTS, Metadata.Track.ALBUM_ID);
|
||||
|
||||
CATEGORY_NAME_TO_TITLE.put(Messages.Category.ALBUM_ARTIST, R.string.artists_title);
|
||||
CATEGORY_NAME_TO_TITLE.put(Messages.Category.GENRE, R.string.genres_title);
|
||||
CATEGORY_NAME_TO_TITLE.put(Messages.Category.ARTIST, R.string.artists_title);
|
||||
CATEGORY_NAME_TO_TITLE.put(Messages.Category.ALBUM, R.string.albums_title);
|
||||
CATEGORY_NAME_TO_TITLE.put(Messages.Category.PLAYLISTS, R.string.playlists_title);
|
||||
|
||||
CATEGORY_NAME_TO_EMPTY_TYPE.put(Messages.Category.ALBUM_ARTIST, R.string.browse_type_artists);
|
||||
CATEGORY_NAME_TO_EMPTY_TYPE.put(Messages.Category.GENRE, R.string.browse_type_genres);
|
||||
CATEGORY_NAME_TO_EMPTY_TYPE.put(Messages.Category.ARTIST, R.string.browse_type_artists);
|
||||
CATEGORY_NAME_TO_EMPTY_TYPE.put(Messages.Category.ALBUM, R.string.browse_type_albums);
|
||||
CATEGORY_NAME_TO_EMPTY_TYPE.put(Messages.Category.PLAYLISTS, R.string.browse_type_playlists);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String category) {
|
||||
return new Intent(context, CategoryBrowseActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY, category);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String category, int deepLinkType) {
|
||||
return new Intent(context, CategoryBrowseActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY, category)
|
||||
.putExtra(EXTRA_DEEP_LINK_TYPE, deepLinkType);
|
||||
}
|
||||
|
||||
private String category;
|
||||
private Adapter adapter;
|
||||
private String lastFilter;
|
||||
private TransportFragment transport;
|
||||
private int deepLinkType;
|
||||
private EmptyListView emptyView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.category = getIntent().getStringExtra(EXTRA_CATEGORY);
|
||||
this.deepLinkType = getIntent().getIntExtra(EXTRA_DEEP_LINK_TYPE, DeepLink.ALBUMS);
|
||||
this.adapter = new Adapter();
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
if (CATEGORY_NAME_TO_TITLE.containsKey(category)) {
|
||||
setTitle(CATEGORY_NAME_TO_TITLE.get(category));
|
||||
}
|
||||
|
||||
final RecyclerFastScroller fastScroller = (RecyclerFastScroller) findViewById(R.id.fast_scroller);
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter);
|
||||
|
||||
emptyView = (EmptyListView) findViewById(R.id.empty_list_view);
|
||||
emptyView.setCapability(EmptyListView.Capability.OnlineOnly);
|
||||
emptyView.setEmptyMessage(getString(R.string.empty_no_items_format, getString(CATEGORY_NAME_TO_EMPTY_TYPE.get(category))));
|
||||
emptyView.setAlternateView(recyclerView);
|
||||
|
||||
Views.enableUpNavigation(this);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
if (!Messages.Category.PLAYLISTS.equals(category)) { /* bleh */
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.lastFilter = filter;
|
||||
this.filterDebouncer.call();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void requery() {
|
||||
final SocketMessage request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryCategory)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build();
|
||||
|
||||
getWebSocketService().send(request, this.socketClient, (SocketMessage response) -> {
|
||||
JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
|
||||
if (data != null && data.length() > 0) {
|
||||
adapter.setModel(data);
|
||||
}
|
||||
|
||||
emptyView.update(getWebSocketService().getState(), adapter.getItemCount());
|
||||
});
|
||||
}
|
||||
|
||||
private final Debouncer<String> filterDebouncer = new Debouncer<String>(350) {
|
||||
@Override
|
||||
protected void onDebounced(String caller) {
|
||||
if (!isPaused()) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client socketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
filterDebouncer.cancel();
|
||||
requery();
|
||||
}
|
||||
else if (newState == WebSocketService.State.Disconnected) {
|
||||
emptyView.update(getWebSocketService().getState(), adapter.getItemCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
final JSONObject entry = (JSONObject) view.getTag();
|
||||
if (deepLinkType == DeepLink.ALBUMS) {
|
||||
navigateToAlbums(entry);
|
||||
}
|
||||
else {
|
||||
navigateToTracks(entry);
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnLongClickListener onItemLongClickListener = (View view) -> {
|
||||
/* if we deep link to albums by default, long press will get to
|
||||
tracks. if we deep link to tracks, just ignore */
|
||||
if (deepLinkType == DeepLink.ALBUMS) {
|
||||
navigateToTracks((JSONObject) view.getTag());
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
private void navigateToAlbums(final JSONObject entry) {
|
||||
final Intent intent = AlbumBrowseActivity.getStartIntent(this, category, entry);
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_BROWSE_ACTIVITY);
|
||||
}
|
||||
|
||||
private void navigateToTracks(final JSONObject entry) {
|
||||
final long categoryId = entry.optLong(Messages.Key.ID);
|
||||
final String value = entry.optString(Messages.Key.VALUE);
|
||||
final Intent intent = TrackListActivity.getStartIntent(this, category, categoryId, value);
|
||||
startActivityForResult(intent, Navigation.RequestCode.CATEGORY_TRACKS_ACTIVITY);
|
||||
}
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
itemView.findViewById(R.id.subtitle).setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry) {
|
||||
final long entryId = entry.optLong(Messages.Key.ID);
|
||||
long playingId = -1;
|
||||
|
||||
final String idKey = CATEGORY_NAME_TO_ID.get(category);
|
||||
if (idKey != null && idKey.length() > 0) {
|
||||
playingId = transport.getPlaybackService().getTrackLong(idKey, -1);
|
||||
}
|
||||
|
||||
int titleColor = R.color.theme_foreground;
|
||||
if (playingId != -1 && entryId == playingId) {
|
||||
titleColor = R.color.theme_green;
|
||||
}
|
||||
|
||||
/* note optString only does a null check! */
|
||||
String value = entry.optString(Messages.Key.VALUE, "");
|
||||
value = Strings.empty(value) ? getString(R.string.unknown_value) : value;
|
||||
|
||||
title.setText(value);
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
|
||||
itemView.setTag(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
private JSONArray model;
|
||||
|
||||
void setModel(JSONArray model) {
|
||||
this.model = model;
|
||||
this.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
view.setOnLongClickListener(onItemLongClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(model.optJSONObject(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (model == null) ? 0 : model.length();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,278 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.ui.extension.addTransportFragment
|
||||
import io.casey.musikcube.remote.ui.extension.enableUpNavigation
|
||||
import io.casey.musikcube.remote.ui.extension.initSearchMenu
|
||||
import io.casey.musikcube.remote.ui.extension.setupDefaultRecyclerView
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView
|
||||
import io.casey.musikcube.remote.util.Debouncer
|
||||
import io.casey.musikcube.remote.util.Navigation
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService.State as SocketState
|
||||
|
||||
class CategoryBrowseActivity : WebSocketActivityBase(), Filterable {
|
||||
interface DeepLink {
|
||||
companion object {
|
||||
val TRACKS = 0
|
||||
val ALBUMS = 1
|
||||
}
|
||||
}
|
||||
|
||||
private var category: String? = null
|
||||
private var adapter: Adapter = Adapter()
|
||||
private var lastFilter: String? = null
|
||||
private var transport: TransportFragment? = null
|
||||
private var deepLinkType: Int = 0
|
||||
private var emptyView: EmptyListView? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
category = intent.getStringExtra(EXTRA_CATEGORY)
|
||||
deepLinkType = intent.getIntExtra(EXTRA_DEEP_LINK_TYPE, DeepLink.ALBUMS)
|
||||
adapter = Adapter()
|
||||
|
||||
setContentView(R.layout.recycler_view_activity)
|
||||
setTitle(categoryTitleStringId)
|
||||
|
||||
val fastScroller = findViewById(R.id.fast_scroller) as RecyclerFastScroller
|
||||
val recyclerView = findViewById(R.id.recycler_view) as RecyclerView
|
||||
setupDefaultRecyclerView(recyclerView, fastScroller, adapter)
|
||||
|
||||
emptyView = findViewById(R.id.empty_list_view) as EmptyListView
|
||||
emptyView?.capability = EmptyListView.Capability.OnlineOnly
|
||||
emptyView?.emptyMessage = getString(R.string.empty_no_items_format, getString(categoryTypeStringId))
|
||||
emptyView?.alternateView = recyclerView
|
||||
|
||||
enableUpNavigation()
|
||||
|
||||
transport = addTransportFragment(object: TransportFragment.OnModelChangedListener {
|
||||
override fun onChanged(fragment: TransportFragment) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
if (Messages.Category.PLAYLISTS != category) { /* bleh */
|
||||
initSearchMenu(menu, this)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (resultCode == Navigation.ResponseCode.PLAYBACK_STARTED) {
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED)
|
||||
finish()
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun setFilter(filter: String) {
|
||||
this.lastFilter = filter
|
||||
this.filterDebouncer.call()
|
||||
}
|
||||
|
||||
override val webSocketServiceClient: WebSocketService.Client?
|
||||
get() = socketClient
|
||||
|
||||
override val playbackServiceEventListener: (() -> Unit)?
|
||||
get() = null
|
||||
|
||||
private val categoryTypeStringId: Int
|
||||
get() {
|
||||
return CATEGORY_NAME_TO_EMPTY_TYPE[category] ?: R.string.unknown_value
|
||||
}
|
||||
|
||||
private val categoryTitleStringId: Int
|
||||
get() {
|
||||
return CATEGORY_NAME_TO_TITLE[category] ?: R.string.unknown_value
|
||||
}
|
||||
|
||||
private fun requery() {
|
||||
val request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryCategory)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build()
|
||||
|
||||
getWebSocketService().send(request, this.socketClient) { response: SocketMessage ->
|
||||
val data = response.getJsonArrayOption(Messages.Key.DATA)
|
||||
if (data != null && data.length() > 0) {
|
||||
adapter.setModel(data)
|
||||
}
|
||||
emptyView?.update(getWebSocketService().state, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
private val filterDebouncer = object : Debouncer<String>(350) {
|
||||
override fun onDebounced(last: String?) {
|
||||
if (!isPaused) {
|
||||
requery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val socketClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState === WebSocketService.State.Connected) {
|
||||
filterDebouncer.cancel()
|
||||
requery()
|
||||
}
|
||||
else if (newState === WebSocketService.State.Disconnected) {
|
||||
emptyView?.update(newState, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
private val onItemClickListener = { view: View ->
|
||||
val entry = view.tag as JSONObject
|
||||
if (deepLinkType == DeepLink.ALBUMS) {
|
||||
navigateToAlbums(entry)
|
||||
}
|
||||
else {
|
||||
navigateToTracks(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private val onItemLongClickListener = { view: View ->
|
||||
/* if we deep link to albums by default, long press will get to
|
||||
tracks. if we deep link to tracks, just ignore */
|
||||
var result = false
|
||||
if (deepLinkType == DeepLink.ALBUMS) {
|
||||
navigateToTracks(view.tag as JSONObject)
|
||||
result = true
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
private fun navigateToAlbums(entry: JSONObject) {
|
||||
val intent = AlbumBrowseActivity.getStartIntent(this, category!!, entry)
|
||||
startActivityForResult(intent, Navigation.RequestCode.ALBUM_BROWSE_ACTIVITY)
|
||||
}
|
||||
|
||||
private fun navigateToTracks(entry: JSONObject) {
|
||||
val categoryId = entry.optLong(Messages.Key.ID)
|
||||
val value = entry.optString(Messages.Key.VALUE)
|
||||
val intent = TrackListActivity.getStartIntent(this, category!!, categoryId, value)
|
||||
startActivityForResult(intent, Navigation.RequestCode.CATEGORY_TRACKS_ACTIVITY)
|
||||
}
|
||||
|
||||
private inner class ViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val title: TextView = itemView.findViewById(R.id.title) as TextView
|
||||
|
||||
init {
|
||||
itemView.findViewById(R.id.subtitle).visibility = View.GONE
|
||||
}
|
||||
|
||||
internal fun bind(entry: JSONObject) {
|
||||
val entryId = entry.optLong(Messages.Key.ID)
|
||||
var playingId: Long = -1
|
||||
|
||||
val idKey = CATEGORY_NAME_TO_ID[category]
|
||||
if (idKey != null && idKey.isNotEmpty()) {
|
||||
playingId = transport?.playbackService?.getTrackLong(idKey, -1) ?: -1L
|
||||
}
|
||||
|
||||
var titleColor = R.color.theme_foreground
|
||||
if (playingId != -1L && entryId == playingId) {
|
||||
titleColor = R.color.theme_green
|
||||
}
|
||||
|
||||
/* note optString only does a null check! */
|
||||
var value = entry.optString(Messages.Key.VALUE, "")
|
||||
value = if (Strings.empty(value)) getString(R.string.unknown_value) else value
|
||||
|
||||
title.text = value
|
||||
title.setTextColor(resources.getColor(titleColor))
|
||||
|
||||
itemView.tag = entry
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Adapter : RecyclerView.Adapter<ViewHolder>() {
|
||||
private var model: JSONArray = JSONArray()
|
||||
|
||||
internal fun setModel(model: JSONArray?) {
|
||||
this.model = model ?: JSONArray()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.simple_list_item, parent, false)
|
||||
view.setOnClickListener(onItemClickListener)
|
||||
view.setOnLongClickListener(onItemLongClickListener)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(model.optJSONObject(position))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return model.length()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXTRA_CATEGORY = "extra_category"
|
||||
private val EXTRA_DEEP_LINK_TYPE = "extra_deep_link_type"
|
||||
|
||||
private val CATEGORY_NAME_TO_ID: Map<String, String> = mapOf(
|
||||
Messages.Category.ALBUM_ARTIST to Metadata.Track.ALBUM_ARTIST_ID,
|
||||
Messages.Category.GENRE to Metadata.Track.GENRE_ID,
|
||||
Messages.Category.ARTIST to Metadata.Track.ARTIST_ID,
|
||||
Messages.Category.ALBUM to Metadata.Track.ALBUM_ID,
|
||||
Messages.Category.PLAYLISTS to Metadata.Track.ALBUM_ID)
|
||||
|
||||
private val CATEGORY_NAME_TO_TITLE: Map<String, Int> = mapOf(
|
||||
Messages.Category.ALBUM_ARTIST to R.string.artists_title,
|
||||
Messages.Category.GENRE to R.string.genres_title,
|
||||
Messages.Category.ARTIST to R.string.artists_title,
|
||||
Messages.Category.ALBUM to R.string.albums_title,
|
||||
Messages.Category.PLAYLISTS to R.string.playlists_title)
|
||||
|
||||
private val CATEGORY_NAME_TO_EMPTY_TYPE: Map<String, Int> = mapOf(
|
||||
Messages.Category.ALBUM_ARTIST to R.string.browse_type_artists,
|
||||
Messages.Category.GENRE to R.string.browse_type_genres,
|
||||
Messages.Category.ARTIST to R.string.browse_type_artists,
|
||||
Messages.Category.ALBUM to R.string.browse_type_albums,
|
||||
Messages.Category.PLAYLISTS to R.string.browse_type_playlists)
|
||||
|
||||
fun getStartIntent(context: Context, category: String): Intent {
|
||||
return Intent(context, CategoryBrowseActivity::class.java)
|
||||
.putExtra(EXTRA_CATEGORY, category)
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context, category: String, deepLinkType: Int): Intent {
|
||||
return Intent(context, CategoryBrowseActivity::class.java)
|
||||
.putExtra(EXTRA_CATEGORY, category)
|
||||
.putExtra(EXTRA_DEEP_LINK_TYPE, deepLinkType)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
public interface Filterable {
|
||||
void setFilter(final String filter);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
interface Filterable {
|
||||
fun setFilter(filter: String)
|
||||
}
|
@ -1,201 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
import static io.casey.musikcube.remote.ui.model.TrackListSlidingWindow.QueryFactory;
|
||||
|
||||
public class PlayQueueActivity extends WebSocketActivityBase {
|
||||
private static String EXTRA_PLAYING_INDEX = "extra_playing_index";
|
||||
|
||||
public static Intent getStartIntent(final Context context, int playingIndex) {
|
||||
return new Intent(context, PlayQueueActivity.class)
|
||||
.putExtra(EXTRA_PLAYING_INDEX, playingIndex);
|
||||
}
|
||||
|
||||
private WebSocketService wss;
|
||||
private TrackListSlidingWindow<JSONObject> tracks;
|
||||
private PlaybackService playback;
|
||||
private Adapter adapter;
|
||||
private boolean offlineQueue;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
this.wss = getWebSocketService();
|
||||
this.playback = getPlaybackService();
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
Views.setTitle(this, R.string.play_queue_title);
|
||||
Views.enableUpNavigation(this);
|
||||
|
||||
this.adapter = new Adapter();
|
||||
|
||||
final RecyclerFastScroller fastScroller = (RecyclerFastScroller) findViewById(R.id.fast_scroller);
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter);
|
||||
|
||||
final QueryFactory queryFactory = this.playback.getPlaylistQueryFactory();
|
||||
|
||||
offlineQueue = Messages.Category.OFFLINE.equals(
|
||||
queryFactory.getRequeryMessage().getStringOption(Messages.Key.CATEGORY));
|
||||
|
||||
this.tracks = new TrackListSlidingWindow<>(
|
||||
recyclerView,
|
||||
fastScroller,
|
||||
this.wss,
|
||||
queryFactory,
|
||||
(JSONObject obj) -> obj);
|
||||
|
||||
this.tracks.setInitialPosition(
|
||||
getIntent().getIntExtra(EXTRA_PLAYING_INDEX, -1));
|
||||
|
||||
Views.addTransportFragment(this);
|
||||
Views.enableUpNavigation(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
this.tracks.pause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
this.tracks.resume(); /* needs to happen before */
|
||||
super.onResume();
|
||||
|
||||
if (offlineQueue) {
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return webSocketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
|
||||
return this.playbackEvents;
|
||||
}
|
||||
|
||||
private final View.OnClickListener onItemClickListener = new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (v.getTag() instanceof Integer) {
|
||||
final int index = (Integer) v.getTag();
|
||||
playback.playAt(index);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final PlaybackService.EventListener playbackEvents = new PlaybackService.EventListener() {
|
||||
@Override
|
||||
public void onStateUpdated() {
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
};
|
||||
|
||||
private final WebSocketService.Client webSocketClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage broadcast) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
private final TextView trackNum;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
trackNum = (TextView) itemView.findViewById(R.id.track_num);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry, int position) {
|
||||
trackNum.setText(String.valueOf(position + 1));
|
||||
itemView.setTag(position);
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (entry == null) {
|
||||
title.setText("-");
|
||||
subtitle.setText("-");
|
||||
}
|
||||
else {
|
||||
final String entryExternalId = entry
|
||||
.optString(Metadata.Track.EXTERNAL_ID, "");
|
||||
|
||||
final String playingExternalId = playback
|
||||
.getTrackString(Metadata.Track.EXTERNAL_ID, "");
|
||||
|
||||
if (entryExternalId.equals(playingExternalId)) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Metadata.Track.TITLE, "-"));
|
||||
subtitle.setText(entry.optString(Metadata.Track.ALBUM_ARTIST, "-"));
|
||||
}
|
||||
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.play_queue_row, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(tracks.getTrack(position), position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (tracks == null) ? 0 : tracks.getCount();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
class PlayQueueActivity : WebSocketActivityBase() {
|
||||
private var tracks: TrackListSlidingWindow? = null
|
||||
private var playback: PlaybackService? = null
|
||||
private var adapter: Adapter = Adapter()
|
||||
private var offlineQueue: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
playback = playbackService
|
||||
|
||||
setContentView(R.layout.recycler_view_activity)
|
||||
|
||||
val fastScroller = findViewById(R.id.fast_scroller) as RecyclerFastScroller
|
||||
val recyclerView = findViewById(R.id.recycler_view) as RecyclerView
|
||||
setupDefaultRecyclerView(recyclerView, fastScroller, adapter)
|
||||
|
||||
val queryFactory = playback?.playlistQueryFactory
|
||||
val message: SocketMessage? = queryFactory?.getRequeryMessage()
|
||||
|
||||
offlineQueue = (Messages.Category.OFFLINE == message?.getStringOption(Messages.Key.CATEGORY))
|
||||
|
||||
tracks = TrackListSlidingWindow(recyclerView, fastScroller, getWebSocketService(), queryFactory)
|
||||
tracks?.setInitialPosition(intent.getIntExtra(EXTRA_PLAYING_INDEX, -1))
|
||||
|
||||
setTitleFromIntent(R.string.play_queue_title)
|
||||
addTransportFragment()
|
||||
enableUpNavigation()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
this.tracks?.pause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
this.tracks?.resume() /* needs to happen before */
|
||||
|
||||
super.onResume()
|
||||
|
||||
if (offlineQueue) {
|
||||
tracks?.requery()
|
||||
}
|
||||
}
|
||||
|
||||
override val webSocketServiceClient: WebSocketService.Client?
|
||||
get() = socketClient
|
||||
|
||||
override val playbackServiceEventListener: (() -> Unit)?
|
||||
get() = playbackEvents
|
||||
|
||||
private val onItemClickListener = View.OnClickListener { v ->
|
||||
if (v.tag is Int) {
|
||||
val index = v.tag as Int
|
||||
playback?.playAt(index)
|
||||
}
|
||||
}
|
||||
|
||||
private val playbackEvents = {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private val socketClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
tracks?.requery()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
private inner class ViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val title: TextView = itemView.findViewById(R.id.title) as TextView
|
||||
private val subtitle: TextView = itemView.findViewById(R.id.subtitle) as TextView
|
||||
private val trackNum: TextView = itemView.findViewById(R.id.track_num) as TextView
|
||||
|
||||
internal fun bind(entry: JSONObject?, position: Int) {
|
||||
trackNum.text = (position + 1).toString()
|
||||
itemView.tag = position
|
||||
var titleColor = R.color.theme_foreground
|
||||
var subtitleColor = R.color.theme_disabled_foreground
|
||||
|
||||
if (entry == null) {
|
||||
title.text = "-"
|
||||
subtitle.text = "-"
|
||||
}
|
||||
else {
|
||||
val entryExternalId = entry.optString(Metadata.Track.EXTERNAL_ID, "")
|
||||
val playingExternalId = playback?.getTrackString(Metadata.Track.EXTERNAL_ID, "")
|
||||
|
||||
if (entryExternalId == playingExternalId) {
|
||||
titleColor = R.color.theme_green
|
||||
subtitleColor = R.color.theme_yellow
|
||||
}
|
||||
|
||||
title.text = entry.optString(Metadata.Track.TITLE, "-")
|
||||
subtitle.text = entry.optString(Metadata.Track.ALBUM_ARTIST, "-")
|
||||
}
|
||||
|
||||
title.setTextColor(resources.getColor(titleColor))
|
||||
subtitle.setTextColor(resources.getColor(subtitleColor))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Adapter : RecyclerView.Adapter<ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.play_queue_row, parent, false)
|
||||
view.setOnClickListener(onItemClickListener)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(tracks?.getTrack(position), position)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return tracks?.count ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXTRA_PLAYING_INDEX = "extra_playing_index"
|
||||
|
||||
fun getStartIntent(context: Context, playingIndex: Int): Intent {
|
||||
return Intent(context, PlayQueueActivity::class.java)
|
||||
.putExtra(EXTRA_PLAYING_INDEX, playingIndex)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,274 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.DialogFragment;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory;
|
||||
import io.casey.musikcube.remote.playback.PlayerWrapper;
|
||||
import io.casey.musikcube.remote.playback.StreamProxy;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity {
|
||||
private EditText addressText, portText, httpPortText, passwordText;
|
||||
private CheckBox albumArtCheckbox, messageCompressionCheckbox, softwareVolume;
|
||||
private CheckBox sslCheckbox, certCheckbox;
|
||||
private Spinner playbackModeSpinner, bitrateSpinner, cacheSpinner;
|
||||
private SharedPreferences prefs;
|
||||
private boolean wasStreaming;
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, SettingsActivity.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
prefs = this.getSharedPreferences(Prefs.NAME, MODE_PRIVATE);
|
||||
setContentView(R.layout.activity_settings);
|
||||
setTitle(R.string.settings_title);
|
||||
wasStreaming = isStreamingEnabled();
|
||||
bindEventListeners();
|
||||
rebindUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.settings_menu, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
else if (item.getItemId() == R.id.action_save) {
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
Views.setTextAndMoveCursorToEnd(this.addressText, prefs.getString(Prefs.Key.ADDRESS, Prefs.Default.ADDRESS));
|
||||
Views.setTextAndMoveCursorToEnd(this.portText, String.format(Locale.ENGLISH, "%d", prefs.getInt(Prefs.Key.MAIN_PORT, Prefs.Default.MAIN_PORT)));
|
||||
Views.setTextAndMoveCursorToEnd(this.httpPortText, String.format(Locale.ENGLISH, "%d", prefs.getInt(Prefs.Key.AUDIO_PORT, Prefs.Default.AUDIO_PORT)));
|
||||
Views.setTextAndMoveCursorToEnd(this.passwordText, prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD));
|
||||
|
||||
final ArrayAdapter<CharSequence> playbackModes = ArrayAdapter.createFromResource(
|
||||
this, R.array.streaming_mode_array, android.R.layout.simple_spinner_item);
|
||||
|
||||
playbackModes.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
playbackModeSpinner.setAdapter(playbackModes);
|
||||
playbackModeSpinner.setSelection(isStreamingEnabled() ? 1 : 0);
|
||||
|
||||
final ArrayAdapter<CharSequence> bitrates = ArrayAdapter.createFromResource(
|
||||
this, R.array.transcode_bitrate_array, android.R.layout.simple_spinner_item);
|
||||
|
||||
bitrates.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
bitrateSpinner.setAdapter(bitrates);
|
||||
bitrateSpinner.setSelection(prefs.getInt(Prefs.Key.TRANSCODER_BITRATE_INDEX, Prefs.Default.TRANSCODER_BITRATE_INDEX));
|
||||
|
||||
final ArrayAdapter<CharSequence> cacheSizes = ArrayAdapter.createFromResource(
|
||||
this, R.array.disk_cache_array, android.R.layout.simple_spinner_item);
|
||||
|
||||
cacheSizes.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
cacheSpinner.setAdapter(cacheSizes);
|
||||
cacheSpinner.setSelection(prefs.getInt(Prefs.Key.DISK_CACHE_SIZE_INDEX, Prefs.Default.DISK_CACHE_SIZE_INDEX));
|
||||
|
||||
this.albumArtCheckbox.setChecked(this.prefs.getBoolean(Prefs.Key.ALBUM_ART_ENABLED, Prefs.Default.ALBUM_ART_ENABLED));
|
||||
this.messageCompressionCheckbox.setChecked(this.prefs.getBoolean(Prefs.Key.MESSAGE_COMPRESSION_ENABLED, Prefs.Default.MESSAGE_COMPRESSION_ENABLED));
|
||||
this.softwareVolume.setChecked(this.prefs.getBoolean(Prefs.Key.SOFTWARE_VOLUME, Prefs.Default.SOFTWARE_VOLUME));
|
||||
|
||||
Views.setCheckWithoutEvent(
|
||||
this.sslCheckbox,
|
||||
this.prefs.getBoolean(
|
||||
Prefs.Key.SSL_ENABLED,
|
||||
Prefs.Default.SSL_ENABLED),
|
||||
sslCheckChanged);
|
||||
|
||||
Views.setCheckWithoutEvent(
|
||||
this.certCheckbox,
|
||||
this.prefs.getBoolean(
|
||||
Prefs.Key.CERT_VALIDATION_DISABLED,
|
||||
Prefs.Default.CERT_VALIDATION_DISABLED),
|
||||
certValidationChanged);
|
||||
|
||||
Views.enableUpNavigation(this);
|
||||
}
|
||||
|
||||
private boolean isStreamingEnabled() {
|
||||
return this.prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK);
|
||||
}
|
||||
|
||||
private boolean isStreamingSelected() {
|
||||
return this.playbackModeSpinner.getSelectedItemPosition() == 1;
|
||||
}
|
||||
|
||||
private void onDisableSslFromDialog() {
|
||||
Views.setCheckWithoutEvent(this.sslCheckbox, false, sslCheckChanged);
|
||||
}
|
||||
|
||||
private void onDisableCertValidationFromDialog() {
|
||||
Views.setCheckWithoutEvent(this.certCheckbox, false, certValidationChanged);
|
||||
}
|
||||
|
||||
private CheckBox.OnCheckedChangeListener sslCheckChanged = (button, value) -> {
|
||||
if (value) {
|
||||
if (getSupportFragmentManager().findFragmentByTag(SslAlertDialog.TAG) == null) {
|
||||
SslAlertDialog.newInstance().show(getSupportFragmentManager(), SslAlertDialog.TAG);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private CheckBox.OnCheckedChangeListener certValidationChanged = (button, value) -> {
|
||||
if (value) {
|
||||
if (getSupportFragmentManager().findFragmentByTag(DisableCertValidationAlertDialog.TAG) == null) {
|
||||
DisableCertValidationAlertDialog.newInstance().show(
|
||||
getSupportFragmentManager(), DisableCertValidationAlertDialog.TAG);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void bindEventListeners() {
|
||||
this.addressText = (EditText) this.findViewById(R.id.address);
|
||||
this.portText = (EditText) this.findViewById(R.id.port);
|
||||
this.httpPortText = (EditText) this.findViewById(R.id.http_port);
|
||||
this.passwordText = (EditText) this.findViewById(R.id.password);
|
||||
this.albumArtCheckbox = (CheckBox) findViewById(R.id.album_art_checkbox);
|
||||
this.messageCompressionCheckbox = (CheckBox) findViewById(R.id.message_compression);
|
||||
this.softwareVolume = (CheckBox) findViewById(R.id.software_volume);
|
||||
this.playbackModeSpinner = (Spinner) findViewById(R.id.playback_mode_spinner);
|
||||
this.bitrateSpinner = (Spinner) findViewById(R.id.transcoder_bitrate_spinner);
|
||||
this.cacheSpinner = (Spinner) findViewById(R.id.streaming_disk_cache_spinner);
|
||||
this.sslCheckbox = (CheckBox) findViewById(R.id.ssl_checkbox);
|
||||
this.certCheckbox = (CheckBox) findViewById(R.id.cert_validation);
|
||||
|
||||
this.playbackModeSpinner.setOnItemSelectedListener(new Spinner.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> adapterView, View view, int selectedIndex, long l) {
|
||||
final boolean streaming = (selectedIndex == 1);
|
||||
bitrateSpinner.setEnabled(streaming);
|
||||
cacheSpinner.setEnabled(streaming);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> adapterView) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void save() {
|
||||
final String addr = addressText.getText().toString();
|
||||
final String port = portText.getText().toString();
|
||||
final String httpPort = httpPortText.getText().toString();
|
||||
final String password = passwordText.getText().toString();
|
||||
|
||||
prefs.edit()
|
||||
.putString(Prefs.Key.ADDRESS, addr)
|
||||
.putInt(Prefs.Key.MAIN_PORT, (port.length() > 0) ? Integer.valueOf(port) : 0)
|
||||
.putInt(Prefs.Key.AUDIO_PORT, (httpPort.length() > 0) ? Integer.valueOf(httpPort) : 0)
|
||||
.putString(Prefs.Key.PASSWORD, password)
|
||||
.putBoolean(Prefs.Key.ALBUM_ART_ENABLED, albumArtCheckbox.isChecked())
|
||||
.putBoolean(Prefs.Key.MESSAGE_COMPRESSION_ENABLED, messageCompressionCheckbox.isChecked())
|
||||
.putBoolean(Prefs.Key.STREAMING_PLAYBACK, isStreamingSelected())
|
||||
.putBoolean(Prefs.Key.SOFTWARE_VOLUME, softwareVolume.isChecked())
|
||||
.putBoolean(Prefs.Key.SSL_ENABLED, sslCheckbox.isChecked())
|
||||
.putBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, certCheckbox.isChecked())
|
||||
.putInt(Prefs.Key.TRANSCODER_BITRATE_INDEX, bitrateSpinner.getSelectedItemPosition())
|
||||
.putInt(Prefs.Key.DISK_CACHE_SIZE_INDEX, cacheSpinner.getSelectedItemPosition())
|
||||
.apply();
|
||||
|
||||
if (!softwareVolume.isChecked()) {
|
||||
PlayerWrapper.Companion.setVolume(1.0f);
|
||||
}
|
||||
|
||||
if (wasStreaming && !isStreamingEnabled()) {
|
||||
PlaybackServiceFactory.streaming(this).stop();
|
||||
}
|
||||
|
||||
StreamProxy.Companion.reload();
|
||||
WebSocketService.getInstance(this).disconnect();
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
public static class SslAlertDialog extends DialogFragment {
|
||||
private static final String LEARN_MORE_URL = "https://github.com/clangen/musikcube/wiki/ssl-server-setup";
|
||||
public static final String TAG = "ssl_alert_dialog_tag";
|
||||
|
||||
public static SslAlertDialog newInstance() {
|
||||
return new SslAlertDialog();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final AlertDialog dlg = new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.settings_ssl_dialog_title)
|
||||
.setMessage(R.string.settings_ssl_dialog_message)
|
||||
.setPositiveButton(R.string.button_enable, null)
|
||||
.setNegativeButton(R.string.button_disable, (dialog, which) -> {
|
||||
((SettingsActivity) getActivity()).onDisableSslFromDialog();
|
||||
})
|
||||
.setNeutralButton(R.string.button_learn_more, (dialog, which) -> {
|
||||
try {
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(LEARN_MORE_URL));
|
||||
startActivity(intent);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
dlg.setCancelable(false);
|
||||
return dlg;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DisableCertValidationAlertDialog extends DialogFragment {
|
||||
public static final String TAG = "disable_cert_verify_dialog";
|
||||
|
||||
public static DisableCertValidationAlertDialog newInstance() {
|
||||
return new DisableCertValidationAlertDialog();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
final AlertDialog dlg = new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.settings_disable_cert_validation_title)
|
||||
.setMessage(R.string.settings_disable_cert_validation_message)
|
||||
.setPositiveButton(R.string.button_enable, null)
|
||||
.setNegativeButton(R.string.button_disable, (dialog, which) -> {
|
||||
((SettingsActivity) getActivity()).onDisableCertValidationFromDialog();
|
||||
})
|
||||
.create();
|
||||
|
||||
dlg.setCancelable(false);
|
||||
return dlg;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,284 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory
|
||||
import io.casey.musikcube.remote.playback.PlayerWrapper
|
||||
import io.casey.musikcube.remote.playback.StreamProxy
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
import io.casey.musikcube.remote.websocket.Prefs.Default as Defaults
|
||||
import io.casey.musikcube.remote.websocket.Prefs.Key as Keys
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
private var addressText: EditText? = null
|
||||
private var portText: EditText? = null
|
||||
private var httpPortText: EditText? = null
|
||||
private var passwordText: EditText? = null
|
||||
private var albumArtCheckbox: CheckBox? = null
|
||||
private var messageCompressionCheckbox: CheckBox? = null
|
||||
private var softwareVolume: CheckBox? = null
|
||||
private var sslCheckbox: CheckBox? = null
|
||||
private var certCheckbox: CheckBox? = null
|
||||
private var playbackModeSpinner: Spinner? = null
|
||||
private var bitrateSpinner: Spinner? = null
|
||||
private var cacheSpinner: Spinner? = null
|
||||
private var prefs: SharedPreferences? = null
|
||||
private var wasStreaming: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
prefs = this.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
setContentView(R.layout.activity_settings)
|
||||
setTitle(R.string.settings_title)
|
||||
wasStreaming = isStreamingEnabled
|
||||
bindEventListeners()
|
||||
rebindUi()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.settings_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
return true
|
||||
} else if (item.itemId == R.id.action_save) {
|
||||
save()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun rebindUi() {
|
||||
/* connection info */
|
||||
addressText?.setTextAndMoveCursorToEnd(prefs!!.getString(Keys.ADDRESS, Defaults.ADDRESS))
|
||||
|
||||
portText?.setTextAndMoveCursorToEnd(String.format(
|
||||
Locale.ENGLISH, "%d", prefs!!.getInt(Keys.MAIN_PORT, Defaults.MAIN_PORT)))
|
||||
|
||||
httpPortText?.setTextAndMoveCursorToEnd(String.format(
|
||||
Locale.ENGLISH, "%d", prefs!!.getInt(Keys.AUDIO_PORT, Defaults.AUDIO_PORT)))
|
||||
|
||||
passwordText?.setTextAndMoveCursorToEnd(prefs!!.getString(Keys.PASSWORD, Defaults.PASSWORD))
|
||||
|
||||
val playbackModes = ArrayAdapter.createFromResource(
|
||||
this, R.array.streaming_mode_array, android.R.layout.simple_spinner_item)
|
||||
|
||||
/* playback mode */
|
||||
playbackModes.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
playbackModeSpinner?.adapter = playbackModes
|
||||
playbackModeSpinner?.setSelection(if (isStreamingEnabled) 1 else 0)
|
||||
|
||||
/* bitrate */
|
||||
val bitrates = ArrayAdapter.createFromResource(
|
||||
this, R.array.transcode_bitrate_array, android.R.layout.simple_spinner_item)
|
||||
|
||||
bitrates.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
bitrateSpinner?.adapter = bitrates
|
||||
bitrateSpinner?.setSelection(prefs!!.getInt(
|
||||
Keys.TRANSCODER_BITRATE_INDEX, Defaults.TRANSCODER_BITRATE_INDEX))
|
||||
|
||||
val cacheSizes = ArrayAdapter.createFromResource(
|
||||
this, R.array.disk_cache_array, android.R.layout.simple_spinner_item)
|
||||
|
||||
/* disk cache */
|
||||
cacheSizes.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
|
||||
cacheSpinner?.adapter = cacheSizes
|
||||
cacheSpinner?.setSelection(prefs!!.getInt(
|
||||
Keys.DISK_CACHE_SIZE_INDEX, Defaults.DISK_CACHE_SIZE_INDEX))
|
||||
|
||||
/* advanced */
|
||||
albumArtCheckbox?.isChecked = prefs!!.getBoolean(
|
||||
Keys.ALBUM_ART_ENABLED, Defaults.ALBUM_ART_ENABLED)
|
||||
|
||||
messageCompressionCheckbox?.isChecked = prefs!!.getBoolean(
|
||||
Keys.MESSAGE_COMPRESSION_ENABLED, Defaults.MESSAGE_COMPRESSION_ENABLED)
|
||||
|
||||
softwareVolume?.isChecked = prefs!!.getBoolean(Keys.SOFTWARE_VOLUME, Defaults.SOFTWARE_VOLUME)
|
||||
|
||||
sslCheckbox?.setCheckWithoutEvent(
|
||||
this.prefs!!.getBoolean(Keys.SSL_ENABLED,Defaults.SSL_ENABLED), sslCheckChanged)
|
||||
|
||||
certCheckbox?.setCheckWithoutEvent(
|
||||
this.prefs!!.getBoolean(Keys.CERT_VALIDATION_DISABLED, Defaults.CERT_VALIDATION_DISABLED),
|
||||
certValidationChanged)
|
||||
|
||||
enableUpNavigation()
|
||||
}
|
||||
|
||||
private val isStreamingEnabled: Boolean
|
||||
get() = this.prefs!!.getBoolean(Keys.STREAMING_PLAYBACK, Defaults.STREAMING_PLAYBACK)
|
||||
|
||||
private val isStreamingSelected: Boolean
|
||||
get() = this.playbackModeSpinner?.selectedItemPosition == 1
|
||||
|
||||
private fun onDisableSslFromDialog() {
|
||||
sslCheckbox?.setCheckWithoutEvent(false, sslCheckChanged)
|
||||
}
|
||||
|
||||
private fun onDisableCertValidationFromDialog() {
|
||||
certCheckbox?.setCheckWithoutEvent(false, certValidationChanged)
|
||||
}
|
||||
|
||||
private val sslCheckChanged = { _: CompoundButton, value:Boolean ->
|
||||
if (value) {
|
||||
if (supportFragmentManager.findFragmentByTag(SslAlertDialog.TAG) == null) {
|
||||
SslAlertDialog.newInstance().show(supportFragmentManager, SslAlertDialog.TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val certValidationChanged = { _: CompoundButton, value: Boolean ->
|
||||
if (value) {
|
||||
if (supportFragmentManager.findFragmentByTag(DisableCertValidationAlertDialog.TAG) == null) {
|
||||
DisableCertValidationAlertDialog.newInstance().show(
|
||||
supportFragmentManager, DisableCertValidationAlertDialog.TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindEventListeners() {
|
||||
this.addressText = this.findViewById(R.id.address) as EditText
|
||||
this.portText = this.findViewById(R.id.port) as EditText
|
||||
this.httpPortText = this.findViewById(R.id.http_port) as EditText
|
||||
this.passwordText = this.findViewById(R.id.password) as EditText
|
||||
this.albumArtCheckbox = findViewById(R.id.album_art_checkbox) as CheckBox
|
||||
this.messageCompressionCheckbox = findViewById(R.id.message_compression) as CheckBox
|
||||
this.softwareVolume = findViewById(R.id.software_volume) as CheckBox
|
||||
this.playbackModeSpinner = findViewById(R.id.playback_mode_spinner) as Spinner
|
||||
this.bitrateSpinner = findViewById(R.id.transcoder_bitrate_spinner) as Spinner
|
||||
this.cacheSpinner = findViewById(R.id.streaming_disk_cache_spinner) as Spinner
|
||||
this.sslCheckbox = findViewById(R.id.ssl_checkbox) as CheckBox
|
||||
this.certCheckbox = findViewById(R.id.cert_validation) as CheckBox
|
||||
|
||||
this.playbackModeSpinner?.onItemSelectedListener = (object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val streaming = position == 1
|
||||
bitrateSpinner?.isEnabled = streaming
|
||||
cacheSpinner?.isEnabled = streaming
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
val addr = addressText?.text.toString()
|
||||
val port = portText?.text.toString()
|
||||
val httpPort = httpPortText?.text.toString()
|
||||
val password = passwordText?.text.toString()
|
||||
|
||||
prefs!!.edit()
|
||||
.putString(Keys.ADDRESS, addr)
|
||||
.putInt(Keys.MAIN_PORT, if (port.isNotEmpty()) Integer.valueOf(port) else 0)
|
||||
.putInt(Keys.AUDIO_PORT, if (httpPort.isNotEmpty()) Integer.valueOf(httpPort) else 0)
|
||||
.putString(Keys.PASSWORD, password)
|
||||
.putBoolean(Keys.ALBUM_ART_ENABLED, albumArtCheckbox!!.isChecked)
|
||||
.putBoolean(Keys.MESSAGE_COMPRESSION_ENABLED, messageCompressionCheckbox!!.isChecked)
|
||||
.putBoolean(Keys.STREAMING_PLAYBACK, isStreamingSelected)
|
||||
.putBoolean(Keys.SOFTWARE_VOLUME, softwareVolume!!.isChecked)
|
||||
.putBoolean(Keys.SSL_ENABLED, sslCheckbox!!.isChecked)
|
||||
.putBoolean(Keys.CERT_VALIDATION_DISABLED, certCheckbox!!.isChecked)
|
||||
.putInt(Keys.TRANSCODER_BITRATE_INDEX, bitrateSpinner!!.selectedItemPosition)
|
||||
.putInt(Keys.DISK_CACHE_SIZE_INDEX, cacheSpinner!!.selectedItemPosition)
|
||||
.apply()
|
||||
|
||||
if (!softwareVolume!!.isChecked) {
|
||||
PlayerWrapper.setVolume(1.0f)
|
||||
}
|
||||
|
||||
if (wasStreaming && !isStreamingEnabled) {
|
||||
PlaybackServiceFactory.streaming(this).stop()
|
||||
}
|
||||
|
||||
StreamProxy.reload()
|
||||
WebSocketService.getInstance(this).disconnect()
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
class SslAlertDialog : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dlg = AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.settings_ssl_dialog_title)
|
||||
.setMessage(R.string.settings_ssl_dialog_message)
|
||||
.setPositiveButton(R.string.button_enable, null)
|
||||
.setNegativeButton(R.string.button_disable) { _, _ ->
|
||||
(activity as SettingsActivity).onDisableSslFromDialog()
|
||||
}
|
||||
.setNeutralButton(R.string.button_learn_more) { _, _ ->
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LEARN_MORE_URL))
|
||||
startActivity(intent)
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
}
|
||||
}
|
||||
.create()
|
||||
|
||||
dlg.setCancelable(false)
|
||||
return dlg
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LEARN_MORE_URL = "https://github.com/clangen/musikcube/wiki/ssl-server-setup"
|
||||
val TAG = "ssl_alert_dialog_tag"
|
||||
|
||||
fun newInstance(): SslAlertDialog {
|
||||
return SslAlertDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DisableCertValidationAlertDialog : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dlg = AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.settings_disable_cert_validation_title)
|
||||
.setMessage(R.string.settings_disable_cert_validation_message)
|
||||
.setPositiveButton(R.string.button_enable, null)
|
||||
.setNegativeButton(R.string.button_disable) { _, _ ->
|
||||
(activity as SettingsActivity).onDisableCertValidationFromDialog()
|
||||
}
|
||||
.create()
|
||||
|
||||
dlg.setCancelable(false)
|
||||
return dlg
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "disable_cert_verify_dialog"
|
||||
|
||||
fun newInstance(): DisableCertValidationAlertDialog {
|
||||
return DisableCertValidationAlertDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getStartIntent(context: Context): Intent {
|
||||
return Intent(context, SettingsActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,353 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment;
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow;
|
||||
import io.casey.musikcube.remote.ui.util.Views;
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView;
|
||||
import io.casey.musikcube.remote.util.Debouncer;
|
||||
import io.casey.musikcube.remote.util.Navigation;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
import static io.casey.musikcube.remote.ui.view.EmptyListView.Capability;
|
||||
import static io.casey.musikcube.remote.ui.model.TrackListSlidingWindow.QueryFactory;
|
||||
|
||||
public class TrackListActivity extends WebSocketActivityBase implements Filterable {
|
||||
private static String EXTRA_CATEGORY_TYPE = "extra_category_type";
|
||||
private static String EXTRA_SELECTED_ID = "extra_selected_id";
|
||||
private static String EXTRA_TITLE_ID = "extra_title_id";
|
||||
|
||||
public static Intent getStartIntent(final Context context, final String type, final long id) {
|
||||
return new Intent(context, TrackListActivity.class)
|
||||
.putExtra(EXTRA_CATEGORY_TYPE, type)
|
||||
.putExtra(EXTRA_SELECTED_ID, id);
|
||||
}
|
||||
|
||||
public static Intent getOfflineStartIntent(final Context context) {
|
||||
return getStartIntent(context, Messages.Category.OFFLINE, 0)
|
||||
.putExtra(EXTRA_TITLE_ID, R.string.offline_tracks_title);
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context,
|
||||
final String type,
|
||||
final long id,
|
||||
final String categoryValue) {
|
||||
final Intent intent = getStartIntent(context, type, id);
|
||||
|
||||
if (Strings.notEmpty(categoryValue)) {
|
||||
intent.putExtra(
|
||||
Views.EXTRA_TITLE,
|
||||
context.getString(R.string.songs_from_category, categoryValue));
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Intent getStartIntent(final Context context) {
|
||||
return new Intent(context, TrackListActivity.class);
|
||||
}
|
||||
|
||||
private TrackListSlidingWindow<JSONObject> tracks;
|
||||
private EmptyListView emptyView;
|
||||
private TransportFragment transport;
|
||||
private String categoryType;
|
||||
private long categoryId;
|
||||
private String lastFilter = "";
|
||||
private Adapter adapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
final Intent intent = getIntent();
|
||||
categoryType = intent.getStringExtra(EXTRA_CATEGORY_TYPE);
|
||||
categoryId = intent.getLongExtra(EXTRA_SELECTED_ID, 0);
|
||||
final int titleId = intent.getIntExtra(EXTRA_TITLE_ID, R.string.songs_title);
|
||||
|
||||
setContentView(R.layout.recycler_view_activity);
|
||||
|
||||
Views.setTitle(this, titleId);
|
||||
Views.enableUpNavigation(this);
|
||||
|
||||
final QueryFactory queryFactory =
|
||||
createCategoryQueryFactory(categoryType, categoryId);
|
||||
|
||||
adapter = new Adapter();
|
||||
final RecyclerFastScroller fastScroller = (RecyclerFastScroller) findViewById(R.id.fast_scroller);
|
||||
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
|
||||
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter);
|
||||
|
||||
emptyView = (EmptyListView) findViewById(R.id.empty_list_view);
|
||||
emptyView.setCapability(isOfflineTracks() ? Capability.OfflineOk : Capability.OnlineOnly);
|
||||
emptyView.setEmptyMessage(getEmptyMessage());
|
||||
emptyView.setAlternateView(recyclerView);
|
||||
|
||||
tracks = new TrackListSlidingWindow<>(
|
||||
recyclerView,
|
||||
fastScroller,
|
||||
getWebSocketService(),
|
||||
queryFactory,
|
||||
(JSONObject track) -> track);
|
||||
|
||||
tracks.setOnMetadataLoadedListener(slidingWindowListener);
|
||||
|
||||
transport = Views.addTransportFragment(this,
|
||||
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
if (!Messages.Category.PLAYLISTS.equals(categoryType)) {
|
||||
Views.initSearchMenu(this, menu, this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
this.tracks.pause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
this.tracks.resume(); /* needs to happen before */
|
||||
super.onResume();
|
||||
requeryIfViewingOfflineCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected WebSocketService.Client getWebSocketServiceClient() {
|
||||
return socketServiceClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
this.lastFilter = filter;
|
||||
this.filterDebouncer.call();
|
||||
}
|
||||
|
||||
private WebSocketService.Client socketServiceClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
if (newState == WebSocketService.State.Connected) {
|
||||
filterDebouncer.cancel();
|
||||
tracks.requery();
|
||||
}
|
||||
else {
|
||||
emptyView.update(newState, adapter.getItemCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private final Debouncer<String> filterDebouncer = new Debouncer<String>(350) {
|
||||
@Override
|
||||
protected void onDebounced(String caller) {
|
||||
if (!isPaused()) {
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private View.OnClickListener onItemClickListener = (View view) -> {
|
||||
int index = (Integer) view.getTag();
|
||||
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
getPlaybackService().play(categoryType, categoryId, index, lastFilter);
|
||||
}
|
||||
else {
|
||||
getPlaybackService().playAll(index, lastFilter);
|
||||
}
|
||||
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
|
||||
finish();
|
||||
};
|
||||
|
||||
private class ViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView title;
|
||||
private final TextView subtitle;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
title = (TextView) itemView.findViewById(R.id.title);
|
||||
subtitle = (TextView) itemView.findViewById(R.id.subtitle);
|
||||
}
|
||||
|
||||
void bind(JSONObject entry, int position) {
|
||||
itemView.setTag(position);
|
||||
|
||||
/* TODO: this colorizing logic is copied from PlayQueueActivity. can we generalize
|
||||
it cleanly somehow? is it worth it? */
|
||||
int titleColor = R.color.theme_foreground;
|
||||
int subtitleColor = R.color.theme_disabled_foreground;
|
||||
|
||||
if (entry != null) {
|
||||
final String entryExternalId = entry
|
||||
.optString(Metadata.Track.EXTERNAL_ID, "");
|
||||
|
||||
final String playingExternalId = transport.getPlaybackService()
|
||||
.getTrackString(Metadata.Track.EXTERNAL_ID, "");
|
||||
|
||||
if (entryExternalId.equals(playingExternalId)) {
|
||||
titleColor = R.color.theme_green;
|
||||
subtitleColor = R.color.theme_yellow;
|
||||
}
|
||||
|
||||
title.setText(entry.optString(Metadata.Track.TITLE, "-"));
|
||||
subtitle.setText(entry.optString(Metadata.Track.ALBUM_ARTIST, "-"));
|
||||
}
|
||||
else {
|
||||
title.setText("-");
|
||||
subtitle.setText("-");
|
||||
}
|
||||
|
||||
title.setTextColor(getResources().getColor(titleColor));
|
||||
subtitle.setTextColor(getResources().getColor(subtitleColor));
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter extends RecyclerView.Adapter<ViewHolder> {
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
final View view = inflater.inflate(R.layout.simple_list_item, parent, false);
|
||||
view.setOnClickListener(onItemClickListener);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
holder.bind(tracks.getTrack(position), position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (tracks == null) ? 0 : tracks.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
private String getEmptyMessage() {
|
||||
if (isOfflineTracks()) {
|
||||
return getString(R.string.empty_no_offline_tracks_message);
|
||||
}
|
||||
|
||||
return getString(R.string.empty_no_items_format, getString(R.string.browse_type_tracks));
|
||||
}
|
||||
|
||||
private boolean isOfflineTracks() {
|
||||
return Messages.Category.OFFLINE.equals(categoryType);
|
||||
}
|
||||
|
||||
private void requeryIfViewingOfflineCache() {
|
||||
if (isOfflineTracks()) {
|
||||
tracks.requery();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidCategory(final String categoryType, long categoryId) {
|
||||
return categoryType != null && categoryType.length() > 0 && categoryId != -1;
|
||||
}
|
||||
|
||||
private QueryFactory createCategoryQueryFactory(
|
||||
final String categoryType, long categoryId) {
|
||||
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
/* tracks for a specified category (album, artists, genres, etc */
|
||||
return new QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean connectionRequired() {
|
||||
return Messages.Category.OFFLINE.equals(categoryType);
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
/* all tracks */
|
||||
return new QueryFactory() {
|
||||
@Override
|
||||
public SocketMessage getRequeryMessage() {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SocketMessage getPageAroundMessage(int offset, int limit) {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private TrackListSlidingWindow.OnMetadataLoadedListener slidingWindowListener
|
||||
= new TrackListSlidingWindow.OnMetadataLoadedListener() {
|
||||
public void onReloaded(int count) {
|
||||
emptyView.update(getWebSocketService().getState(), count);
|
||||
}
|
||||
|
||||
public void onMetadataLoaded(int offset, int count) {
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,312 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView
|
||||
import io.casey.musikcube.remote.util.Debouncer
|
||||
import io.casey.musikcube.remote.util.Navigation
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
import io.casey.musikcube.remote.ui.view.EmptyListView.Capability
|
||||
import io.casey.musikcube.remote.ui.model.TrackListSlidingWindow.QueryFactory
|
||||
|
||||
class TrackListActivity : WebSocketActivityBase(), Filterable {
|
||||
private var tracks: TrackListSlidingWindow? = null
|
||||
private var emptyView: EmptyListView? = null
|
||||
private var transport: TransportFragment? = null
|
||||
private var categoryType: String? = null
|
||||
private var categoryId: Long = 0
|
||||
private var lastFilter = ""
|
||||
private var adapter: Adapter = Adapter()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = intent
|
||||
categoryType = intent.getStringExtra(EXTRA_CATEGORY_TYPE)
|
||||
categoryId = intent.getLongExtra(EXTRA_SELECTED_ID, 0)
|
||||
val titleId = intent.getIntExtra(EXTRA_TITLE_ID, R.string.songs_title)
|
||||
|
||||
setContentView(R.layout.recycler_view_activity)
|
||||
|
||||
setTitleFromIntent(titleId)
|
||||
enableUpNavigation()
|
||||
|
||||
val queryFactory = createCategoryQueryFactory(categoryType, categoryId)
|
||||
|
||||
val fastScroller = findViewById(R.id.fast_scroller) as RecyclerFastScroller
|
||||
val recyclerView = findViewById(R.id.recycler_view) as RecyclerView
|
||||
setupDefaultRecyclerView(recyclerView, fastScroller, adapter)
|
||||
|
||||
emptyView = findViewById(R.id.empty_list_view) as EmptyListView
|
||||
emptyView?.capability = if (isOfflineTracks) Capability.OfflineOk else Capability.OnlineOnly
|
||||
emptyView?.emptyMessage = emptyMessage
|
||||
emptyView?.alternateView = recyclerView
|
||||
|
||||
tracks = TrackListSlidingWindow(
|
||||
recyclerView, fastScroller, getWebSocketService(), queryFactory)
|
||||
|
||||
tracks?.setOnMetadataLoadedListener(slidingWindowListener)
|
||||
|
||||
transport = addTransportFragment(object: TransportFragment.OnModelChangedListener {
|
||||
override fun onChanged(fragment: TransportFragment) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
if (Messages.Category.PLAYLISTS != categoryType) {
|
||||
initSearchMenu(menu, this)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
tracks?.pause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
tracks?.resume() /* needs to happen before */
|
||||
super.onResume()
|
||||
requeryIfViewingOfflineCache()
|
||||
}
|
||||
|
||||
override val webSocketServiceClient: WebSocketService.Client?
|
||||
get() = socketClient
|
||||
|
||||
override val playbackServiceEventListener: (() -> Unit)?
|
||||
get() = null
|
||||
|
||||
override fun setFilter(filter: String) {
|
||||
lastFilter = filter
|
||||
filterDebouncer.call()
|
||||
}
|
||||
|
||||
private val socketClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
if (newState === WebSocketService.State.Connected) {
|
||||
filterDebouncer.cancel()
|
||||
tracks?.requery()
|
||||
}
|
||||
else {
|
||||
emptyView?.update(newState, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
private val filterDebouncer = object : Debouncer<String>(350) {
|
||||
override fun onDebounced(last: String?) {
|
||||
if (!isPaused) {
|
||||
tracks?.requery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val onItemClickListener = { view: View ->
|
||||
val index = view.tag as Int
|
||||
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
playbackService?.play(categoryType!!, categoryId, index, lastFilter)
|
||||
}
|
||||
else {
|
||||
playbackService?.playAll(index, lastFilter)
|
||||
}
|
||||
|
||||
setResult(Navigation.ResponseCode.PLAYBACK_STARTED)
|
||||
finish()
|
||||
}
|
||||
|
||||
private inner class ViewHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val title: TextView = itemView.findViewById(R.id.title) as TextView
|
||||
private val subtitle: TextView = itemView.findViewById(R.id.subtitle) as TextView
|
||||
|
||||
internal fun bind(entry: JSONObject?, position: Int) {
|
||||
itemView.tag = position
|
||||
|
||||
/* TODO: this colorizing logic matches copied from PlayQueueActivity. can we generalize
|
||||
it cleanly somehow? matches it worth it? */
|
||||
var titleColor = R.color.theme_foreground
|
||||
var subtitleColor = R.color.theme_disabled_foreground
|
||||
|
||||
if (entry != null) {
|
||||
val entryExternalId = entry.optString(Metadata.Track.EXTERNAL_ID, "")
|
||||
val playingExternalId = transport?.playbackService?.getTrackString(Metadata.Track.EXTERNAL_ID, "")
|
||||
|
||||
if (entryExternalId == playingExternalId) {
|
||||
titleColor = R.color.theme_green
|
||||
subtitleColor = R.color.theme_yellow
|
||||
}
|
||||
|
||||
title.text = entry.optString(Metadata.Track.TITLE, "-")
|
||||
subtitle.text = entry.optString(Metadata.Track.ALBUM_ARTIST, "-")
|
||||
}
|
||||
else {
|
||||
title.text = "-"
|
||||
subtitle.text = "-"
|
||||
}
|
||||
|
||||
title.setTextColor(resources.getColor(titleColor))
|
||||
subtitle.setTextColor(resources.getColor(subtitleColor))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Adapter : RecyclerView.Adapter<ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.simple_list_item, parent, false)
|
||||
view.setOnClickListener(onItemClickListener)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(tracks?.getTrack(position), position)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (tracks == null) 0 else tracks!!.count
|
||||
}
|
||||
}
|
||||
|
||||
private val emptyMessage: String
|
||||
get() {
|
||||
if (isOfflineTracks) {
|
||||
return getString(R.string.empty_no_offline_tracks_message)
|
||||
}
|
||||
|
||||
return getString(R.string.empty_no_items_format, getString(R.string.browse_type_tracks))
|
||||
}
|
||||
|
||||
private val isOfflineTracks: Boolean
|
||||
get() = Messages.Category.OFFLINE == categoryType
|
||||
|
||||
private fun requeryIfViewingOfflineCache() {
|
||||
if (isOfflineTracks) {
|
||||
tracks!!.requery()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCategoryQueryFactory(categoryType: String?, categoryId: Long): QueryFactory {
|
||||
if (isValidCategory(categoryType, categoryId)) {
|
||||
/* tracks for a specified category (album, artists, genres, etc */
|
||||
return object : QueryFactory() {
|
||||
override fun getRequeryMessage(): SocketMessage? {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPageAroundMessage(offset: Int, limit: Int): SocketMessage? {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.CATEGORY, categoryType)
|
||||
.addOption(Messages.Key.ID, categoryId)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun connectionRequired(): Boolean {
|
||||
return Messages.Category.OFFLINE == categoryType
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
/* all tracks */
|
||||
return object : QueryFactory() {
|
||||
override fun getRequeryMessage(): SocketMessage? {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.addOption(Messages.Key.COUNT_ONLY, true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getPageAroundMessage(offset: Int, limit: Int): SocketMessage? {
|
||||
return SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracks)
|
||||
.addOption(Messages.Key.OFFSET, offset)
|
||||
.addOption(Messages.Key.LIMIT, limit)
|
||||
.addOption(Messages.Key.FILTER, lastFilter)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val slidingWindowListener = object : TrackListSlidingWindow.OnMetadataLoadedListener {
|
||||
override fun onReloaded(count: Int) {
|
||||
emptyView?.update(getWebSocketService().state, count)
|
||||
}
|
||||
|
||||
override fun onMetadataLoaded(offset: Int, count: Int) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXTRA_CATEGORY_TYPE = "extra_category_type"
|
||||
private val EXTRA_SELECTED_ID = "extra_selected_id"
|
||||
private val EXTRA_TITLE_ID = "extra_title_id"
|
||||
|
||||
fun getStartIntent(context: Context, type: String, id: Long): Intent {
|
||||
return Intent(context, TrackListActivity::class.java)
|
||||
.putExtra(EXTRA_CATEGORY_TYPE, type)
|
||||
.putExtra(EXTRA_SELECTED_ID, id)
|
||||
}
|
||||
|
||||
fun getOfflineStartIntent(context: Context): Intent {
|
||||
return getStartIntent(context, Messages.Category.OFFLINE, 0)
|
||||
.putExtra(EXTRA_TITLE_ID, R.string.offline_tracks_title)
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context, type: String, id: Long, categoryValue: String): Intent {
|
||||
val intent = getStartIntent(context, type, id)
|
||||
|
||||
if (Strings.notEmpty(categoryValue)) {
|
||||
intent.putExtra(
|
||||
EXTRA_ACTIVITY_TITLE,
|
||||
context.getString(R.string.songs_from_category, categoryValue))
|
||||
}
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
fun getStartIntent(context: Context): Intent {
|
||||
return Intent(context, TrackListActivity::class.java)
|
||||
}
|
||||
|
||||
private fun isValidCategory(categoryType: String?, categoryId: Long): Boolean {
|
||||
return categoryType != null && categoryType.isNotEmpty() && categoryId != -1L
|
||||
}
|
||||
}
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import com.uacf.taskrunner.LifecycleDelegate;
|
||||
import com.uacf.taskrunner.Runner;
|
||||
import com.uacf.taskrunner.Task;
|
||||
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public abstract class WebSocketActivityBase extends AppCompatActivity implements Runner.TaskCallbacks {
|
||||
private WebSocketService wss;
|
||||
private boolean paused = true;
|
||||
private LifecycleDelegate runnerDelegate;
|
||||
private PlaybackService playback;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
this.runnerDelegate = new LifecycleDelegate(this, this, getClass(), null);
|
||||
this.runnerDelegate.onCreate(savedInstanceState);
|
||||
this.wss = WebSocketService.getInstance(this);
|
||||
this.playback = PlaybackServiceFactory.instance(this);
|
||||
this.prefs = getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
this.runnerDelegate.onPause();
|
||||
this.wss.removeClient(getWebSocketServiceClient());
|
||||
this.playback.disconnect(getPlaybackServiceEventListener());
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
this.runnerDelegate.onResume();
|
||||
this.playback = PlaybackServiceFactory.instance(this);
|
||||
this.playback.connect(getPlaybackServiceEventListener());
|
||||
this.wss.addClient(getWebSocketServiceClient());
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
this.runnerDelegate.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
this.runnerDelegate.onSaveInstanceState(outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
boolean streaming = prefs.getBoolean(
|
||||
Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK);
|
||||
|
||||
/* if we're not streaming we want the hardware buttons to go out to the system */
|
||||
if (!streaming) {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
playback.volumeDown();
|
||||
return true;
|
||||
}
|
||||
else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
playback.volumeUp();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskCompleted(String s, long l, Task task, Object o) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskError(String s, long l, Task task, Throwable throwable) {
|
||||
|
||||
}
|
||||
|
||||
protected void reloadPlaybackService() {
|
||||
if (!paused && this.playback != null) {
|
||||
this.playback.disconnect(getPlaybackServiceEventListener());
|
||||
this.playback = PlaybackServiceFactory.instance(this);
|
||||
this.playback.connect(getPlaybackServiceEventListener());
|
||||
}
|
||||
}
|
||||
|
||||
protected final Runner runner() {
|
||||
return this.runnerDelegate.runner();
|
||||
}
|
||||
|
||||
protected final boolean isPaused() {
|
||||
return this.paused;
|
||||
}
|
||||
|
||||
protected final WebSocketService getWebSocketService() {
|
||||
return this.wss;
|
||||
}
|
||||
|
||||
protected final PlaybackService getPlaybackService() {
|
||||
return this.playback;
|
||||
}
|
||||
|
||||
protected abstract WebSocketService.Client getWebSocketServiceClient();
|
||||
protected abstract PlaybackService.EventListener getPlaybackServiceEventListener();
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
package io.casey.musikcube.remote.ui.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
|
||||
import com.uacf.taskrunner.LifecycleDelegate
|
||||
import com.uacf.taskrunner.Runner
|
||||
import com.uacf.taskrunner.Task
|
||||
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
|
||||
abstract class WebSocketActivityBase : AppCompatActivity(), Runner.TaskCallbacks {
|
||||
private var runnerDelegate: LifecycleDelegate? = null
|
||||
private var prefs: SharedPreferences? = null
|
||||
private var wss: WebSocketService? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
volumeControlStream = AudioManager.STREAM_MUSIC
|
||||
runnerDelegate = LifecycleDelegate(this, this, javaClass, null)
|
||||
runnerDelegate?.onCreate(savedInstanceState)
|
||||
wss = WebSocketService.getInstance(this)
|
||||
playbackService = PlaybackServiceFactory.instance(this)
|
||||
prefs = getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
runnerDelegate?.onPause()
|
||||
|
||||
val playbackListener = playbackServiceEventListener
|
||||
if (playbackListener != null) {
|
||||
playbackService?.disconnect(playbackServiceEventListener!!)
|
||||
}
|
||||
|
||||
val wssClient = webSocketServiceClient
|
||||
if (wssClient != null) {
|
||||
wss?.removeClient(webSocketServiceClient!!)
|
||||
}
|
||||
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
runnerDelegate?.onResume()
|
||||
|
||||
playbackService = PlaybackServiceFactory.instance(this)
|
||||
|
||||
val playbackListener = playbackServiceEventListener
|
||||
if (playbackListener != null) {
|
||||
this.playbackService?.connect(playbackServiceEventListener!!)
|
||||
}
|
||||
|
||||
val wssClient = webSocketServiceClient
|
||||
if (wssClient != null) {
|
||||
wss?.addClient(webSocketServiceClient!!)
|
||||
}
|
||||
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runnerDelegate?.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
runnerDelegate?.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
val streaming = prefs!!.getBoolean(
|
||||
Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK)
|
||||
|
||||
/* if we're not streaming we want the hardware buttons to go out to the system */
|
||||
if (!streaming) {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
playbackService?.volumeDown()
|
||||
return true
|
||||
}
|
||||
else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
playbackService?.volumeUp()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onTaskCompleted(s: String, l: Long, task: Task<*, *>, o: Any) {
|
||||
|
||||
}
|
||||
|
||||
override fun onTaskError(s: String, l: Long, task: Task<*, *>, throwable: Throwable) {
|
||||
|
||||
}
|
||||
|
||||
protected fun getWebSocketService(): WebSocketService {
|
||||
return wss!!
|
||||
}
|
||||
|
||||
protected var isPaused = true
|
||||
private set
|
||||
|
||||
protected var playbackService: PlaybackService? = null
|
||||
private set
|
||||
|
||||
protected val runner: Runner
|
||||
get() = runnerDelegate!!.runner()
|
||||
|
||||
protected fun reloadPlaybackService() {
|
||||
if (!isPaused && playbackService != null) {
|
||||
val playbackListener = playbackServiceEventListener
|
||||
|
||||
if (playbackListener != null) {
|
||||
playbackService?.disconnect(playbackServiceEventListener!!)
|
||||
}
|
||||
|
||||
playbackService = PlaybackServiceFactory.instance(this)
|
||||
|
||||
if (playbackListener != null) {
|
||||
playbackService?.connect(playbackServiceEventListener!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val webSocketServiceClient: WebSocketService.Client?
|
||||
protected abstract val playbackServiceEventListener: (() -> Unit)?
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
package io.casey.musikcube.remote.ui.extension
|
||||
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.support.v4.view.MenuItemCompat
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.widget.DividerItemDecoration
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.support.v7.widget.SearchView
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.widget.CheckBox
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.EditText
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.ui.activity.Filterable
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
|
||||
var EXTRA_ACTIVITY_TITLE = "extra_title"
|
||||
|
||||
fun AppCompatActivity.setupDefaultRecyclerView(
|
||||
recyclerView: RecyclerView,
|
||||
fastScroller: RecyclerFastScroller,
|
||||
adapter: RecyclerView.Adapter<*>) {
|
||||
val layoutManager = LinearLayoutManager(this)
|
||||
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
fastScroller.attachRecyclerView(recyclerView)
|
||||
|
||||
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
|
||||
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
|
||||
fun AppCompatActivity.enableUpNavigation() {
|
||||
val ab = this.supportActionBar
|
||||
ab?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
fun AppCompatActivity.addTransportFragment(
|
||||
listener: TransportFragment.OnModelChangedListener? = null): TransportFragment?
|
||||
{
|
||||
val root = this.findViewById(android.R.id.content)
|
||||
if (root != null) {
|
||||
if (root.findViewById(R.id.transport_container) != null) {
|
||||
val fragment = TransportFragment.newInstance()
|
||||
|
||||
this.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(R.id.transport_container, fragment, TransportFragment.TAG)
|
||||
.commit()
|
||||
|
||||
fragment.modelChangedListener = listener
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
fun AppCompatActivity.setTitleFromIntent(defaultId: Int) {
|
||||
val title = this.intent.getStringExtra(EXTRA_ACTIVITY_TITLE)
|
||||
if (Strings.notEmpty(title)) {
|
||||
this.title = title
|
||||
}
|
||||
else {
|
||||
this.setTitle(defaultId)
|
||||
}
|
||||
}
|
||||
|
||||
fun AppCompatActivity.initSearchMenu(menu: Menu, filterable: Filterable?) {
|
||||
this.menuInflater.inflate(R.menu.search_menu, menu)
|
||||
|
||||
val searchMenuItem = menu.findItem(R.id.action_search)
|
||||
val searchView = MenuItemCompat.getActionView(searchMenuItem) as SearchView
|
||||
|
||||
searchView.maxWidth = Integer.MAX_VALUE
|
||||
|
||||
if (filterable != null) {
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String): Boolean {
|
||||
filterable.setFilter(newText)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
searchView.setOnCloseListener {
|
||||
filterable.setFilter("")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val searchManager = this.getSystemService(Context.SEARCH_SERVICE) as SearchManager
|
||||
val searchableInfo = searchManager.getSearchableInfo(this.componentName)
|
||||
|
||||
searchView.setSearchableInfo(searchableInfo)
|
||||
searchView.setIconifiedByDefault(true)
|
||||
}
|
||||
|
||||
fun CheckBox.setCheckWithoutEvent(checked: Boolean,
|
||||
listener: (CompoundButton, Boolean) -> Unit) {
|
||||
this.setOnCheckedChangeListener(null)
|
||||
this.isChecked = checked
|
||||
this.setOnCheckedChangeListener(listener)
|
||||
}
|
||||
|
||||
fun EditText.setTextAndMoveCursorToEnd(text: String) {
|
||||
this.setText(text)
|
||||
this.setSelection(this.text.length)
|
||||
}
|
||||
|
||||
fun View.setVisible(visible: Boolean) {
|
||||
this.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.fragment;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.app.DialogFragment;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.ui.activity.SettingsActivity;
|
||||
|
||||
public class InvalidPasswordDialogFragment extends DialogFragment {
|
||||
public static final String TAG = "InvalidPasswordDialogFragment";
|
||||
|
||||
public static InvalidPasswordDialogFragment newInstance() {
|
||||
return new InvalidPasswordDialogFragment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
return new AlertDialog.Builder(getActivity())
|
||||
.setTitle(R.string.invalid_password_dialog_title)
|
||||
.setMessage(R.string.invalid_password_dialog_message)
|
||||
.setPositiveButton(R.string.button_settings, (dialog, which) -> {
|
||||
startActivity(SettingsActivity.getStartIntent(getActivity()));
|
||||
})
|
||||
.setNegativeButton(R.string.button_close, null)
|
||||
.create();
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package io.casey.musikcube.remote.ui.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AlertDialog
|
||||
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.ui.activity.SettingsActivity
|
||||
|
||||
class InvalidPasswordDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.invalid_password_dialog_title)
|
||||
.setMessage(R.string.invalid_password_dialog_message)
|
||||
.setPositiveButton(R.string.button_settings) { _, _ ->
|
||||
startActivity(SettingsActivity.getStartIntent(activity))
|
||||
}
|
||||
.setNegativeButton(R.string.button_close, null)
|
||||
.create()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "InvalidPasswordDialogFragment"
|
||||
|
||||
fun newInstance(): InvalidPasswordDialogFragment {
|
||||
return InvalidPasswordDialogFragment()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.fragment;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import io.casey.musikcube.remote.MainActivity;
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory;
|
||||
import io.casey.musikcube.remote.playback.PlaybackState;
|
||||
import io.casey.musikcube.remote.ui.activity.PlayQueueActivity;
|
||||
|
||||
public class TransportFragment extends Fragment {
|
||||
public static final String TAG = "TransportFragment";
|
||||
|
||||
public static TransportFragment newInstance() {
|
||||
return new TransportFragment();
|
||||
}
|
||||
|
||||
private View rootView, buffering;
|
||||
private TextView title, playPause;
|
||||
private PlaybackService playback;
|
||||
private OnModelChangedListener modelChangedListener;
|
||||
|
||||
public interface OnModelChangedListener {
|
||||
void onChanged(TransportFragment fragment);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater,
|
||||
@Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState)
|
||||
{
|
||||
this.rootView = inflater.inflate(R.layout.fragment_transport, container, false);
|
||||
bindEventHandlers();
|
||||
rebindUi();
|
||||
return this.rootView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
this.playback = PlaybackServiceFactory.instance(getActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
this.playback.disconnect(this.playbackListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
rebindUi();
|
||||
this.playback.connect(this.playbackListener);
|
||||
}
|
||||
|
||||
public PlaybackService getPlaybackService() {
|
||||
return playback;
|
||||
}
|
||||
|
||||
public void setModelChangedListener(OnModelChangedListener modelChangedListener) {
|
||||
this.modelChangedListener = modelChangedListener;
|
||||
}
|
||||
|
||||
private void bindEventHandlers() {
|
||||
this.title = (TextView) this.rootView.findViewById(R.id.track_title);
|
||||
this.buffering = this.rootView.findViewById(R.id.buffering);
|
||||
|
||||
final View titleBar = this.rootView.findViewById(R.id.title_bar);
|
||||
titleBar.setOnClickListener((View view) -> {
|
||||
if (playback.getPlaybackState() != PlaybackState.Stopped) {
|
||||
final Intent intent = PlayQueueActivity
|
||||
.getStartIntent(getActivity(), playback.getQueuePosition())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
startActivity(intent);
|
||||
}
|
||||
});
|
||||
|
||||
this.title.setOnLongClickListener((View view) -> {
|
||||
startActivity(MainActivity.getStartIntent(getActivity()));
|
||||
return true;
|
||||
});
|
||||
|
||||
this.rootView.findViewById(R.id.button_prev).setOnClickListener((View view) -> playback.prev());
|
||||
|
||||
this.playPause = (TextView) this.rootView.findViewById(R.id.button_play_pause);
|
||||
|
||||
this.playPause.setOnClickListener((View view) -> {
|
||||
if (playback.getPlaybackState() == PlaybackState.Stopped) {
|
||||
playback.playAll();
|
||||
}
|
||||
else {
|
||||
playback.pauseOrResume();
|
||||
}
|
||||
});
|
||||
|
||||
this.rootView.findViewById(R.id.button_next).setOnClickListener((View view) -> playback.next());
|
||||
}
|
||||
|
||||
private void rebindUi() {
|
||||
PlaybackState state = playback.getPlaybackState();
|
||||
|
||||
final boolean playing = (state == PlaybackState.Playing);
|
||||
final boolean buffering = (state == PlaybackState.Buffering);
|
||||
|
||||
this.playPause.setText(playing ? R.string.button_pause : R.string.button_play);
|
||||
this.buffering.setVisibility(buffering ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (state == PlaybackState.Stopped) {
|
||||
title.setTextColor(getActivity().getResources().getColor(R.color.theme_disabled_foreground));
|
||||
title.setText(R.string.transport_not_playing);
|
||||
}
|
||||
else {
|
||||
title.setTextColor(getActivity().getResources().getColor(R.color.theme_green));
|
||||
|
||||
final String defaultValue = getString(buffering ? R.string.buffering : R.string.unknown_title);
|
||||
title.setText(playback.getTrackString(Metadata.Track.TITLE, defaultValue));
|
||||
}
|
||||
}
|
||||
|
||||
private PlaybackService.EventListener playbackListener = new PlaybackService.EventListener() {
|
||||
@Override
|
||||
public void onStateUpdated() {
|
||||
rebindUi();
|
||||
|
||||
if (modelChangedListener != null) {
|
||||
modelChangedListener.onChanged(TransportFragment.this);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
package io.casey.musikcube.remote.ui.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
|
||||
import io.casey.musikcube.remote.MainActivity
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.Metadata
|
||||
import io.casey.musikcube.remote.playback.PlaybackService
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory
|
||||
import io.casey.musikcube.remote.playback.PlaybackState
|
||||
import io.casey.musikcube.remote.ui.activity.PlayQueueActivity
|
||||
|
||||
class TransportFragment : Fragment() {
|
||||
private var rootView: View? = null
|
||||
private var buffering: View? = null
|
||||
private var title: TextView? = null
|
||||
private var playPause: TextView? = null
|
||||
|
||||
interface OnModelChangedListener {
|
||||
fun onChanged(fragment: TransportFragment)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater?,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View?
|
||||
{
|
||||
this.rootView = inflater!!.inflate(R.layout.fragment_transport, container, false)
|
||||
bindEventHandlers()
|
||||
rebindUi()
|
||||
return this.rootView
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
this.playbackService = PlaybackServiceFactory.instance(activity)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
this.playbackService?.disconnect(playbackListener)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
rebindUi()
|
||||
this.playbackService?.connect(playbackListener)
|
||||
}
|
||||
|
||||
var playbackService: PlaybackService? = null
|
||||
private set
|
||||
|
||||
var modelChangedListener: OnModelChangedListener? = null
|
||||
set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
private fun bindEventHandlers() {
|
||||
this.title = this.rootView?.findViewById(R.id.track_title) as TextView
|
||||
this.buffering = this.rootView?.findViewById(R.id.buffering)
|
||||
|
||||
val titleBar = this.rootView?.findViewById(R.id.title_bar)
|
||||
|
||||
titleBar?.setOnClickListener { _: View ->
|
||||
if (playbackService?.playbackState != PlaybackState.Stopped) {
|
||||
val intent = PlayQueueActivity
|
||||
.getStartIntent(activity, playbackService?.queuePosition ?: 0)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
this.title?.setOnLongClickListener { _: View ->
|
||||
startActivity(MainActivity.getStartIntent(activity))
|
||||
true
|
||||
}
|
||||
|
||||
this.rootView?.findViewById(R.id.button_prev)?.setOnClickListener { _: View -> playbackService?.prev() }
|
||||
|
||||
this.playPause = this.rootView?.findViewById(R.id.button_play_pause) as TextView
|
||||
|
||||
this.playPause?.setOnClickListener { _: View ->
|
||||
if (playbackService?.playbackState == PlaybackState.Stopped) {
|
||||
playbackService?.playAll()
|
||||
}
|
||||
else {
|
||||
playbackService?.pauseOrResume()
|
||||
}
|
||||
}
|
||||
|
||||
this.rootView?.findViewById(R.id.button_next)?.setOnClickListener { _: View -> playbackService?.next() }
|
||||
}
|
||||
|
||||
private fun rebindUi() {
|
||||
val state = playbackService?.playbackState
|
||||
|
||||
val playing = state == PlaybackState.Playing
|
||||
val buffering = state == PlaybackState.Buffering
|
||||
|
||||
this.playPause?.setText(if (playing) R.string.button_pause else R.string.button_play)
|
||||
this.buffering?.visibility = if (buffering) View.VISIBLE else View.GONE
|
||||
|
||||
if (state == PlaybackState.Stopped) {
|
||||
title?.setTextColor(activity.resources.getColor(R.color.theme_disabled_foreground))
|
||||
title?.setText(R.string.transport_not_playing)
|
||||
}
|
||||
else {
|
||||
title?.setTextColor(activity.resources.getColor(R.color.theme_green))
|
||||
|
||||
val defaultValue = getString(if (buffering) R.string.buffering else R.string.unknown_title)
|
||||
title?.text = playbackService?.getTrackString(Metadata.Track.TITLE, defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
private val playbackListener: () -> Unit = {
|
||||
rebindUi()
|
||||
modelChangedListener?.onChanged(this@TransportFragment)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "TransportFragment"
|
||||
|
||||
fun newInstance(): TransportFragment {
|
||||
return TransportFragment()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,263 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.model;
|
||||
|
||||
import android.util.LruCache;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public final class AlbumArtModel {
|
||||
/* http://www.last.fm/group/Last.fm+Web+Services/forum/21604/_/522900 -- it's ok to
|
||||
put our key in the code */
|
||||
private static final String LAST_FM_ALBUM_INFO =
|
||||
"http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" +
|
||||
"502c69bd3f9946e8e0beee4fcb28c4cd&artist=%s&album=%s&format=json";
|
||||
|
||||
private static OkHttpClient OK_HTTP;
|
||||
|
||||
private static LruCache<Integer, String> URL_CACHE = new LruCache<>(500);
|
||||
|
||||
static {
|
||||
OK_HTTP = new OkHttpClient.Builder()
|
||||
.addInterceptor((Interceptor.Chain chain) -> {
|
||||
Request request = chain.request();
|
||||
|
||||
int count = 0;
|
||||
while (count++ < 3) {
|
||||
try {
|
||||
Response response = chain.proceed(request);
|
||||
if (response.isSuccessful()) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch (SocketTimeoutException ex) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("retries exhausted");
|
||||
})
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String track, artist, album, url;
|
||||
private AlbumArtCallback callback;
|
||||
private boolean fetching;
|
||||
private boolean noart;
|
||||
private int id;
|
||||
private Size desiredSize;
|
||||
|
||||
public enum Size {
|
||||
Small("small", 0),
|
||||
Medium("medium", 1),
|
||||
Large("large", 2),
|
||||
ExtraLarge("extralarge", 3),
|
||||
Mega("mega", 4);
|
||||
|
||||
final String name;
|
||||
final int order;
|
||||
|
||||
static Size from(final String value) {
|
||||
for (Size size : values()) {
|
||||
if (size.name.equals(value)) {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Size(String name, int order) {
|
||||
this.name = name;
|
||||
this.order = order;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Image {
|
||||
final String url;
|
||||
final Size size;
|
||||
|
||||
public Image(final Size size, final String url) {
|
||||
this.url = url;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
public static AlbumArtModel empty() {
|
||||
return new AlbumArtModel("", "", "", Size.Small, null);
|
||||
}
|
||||
|
||||
public AlbumArtModel(String track, String artist, String album, Size size, AlbumArtCallback callback) {
|
||||
this.track = track;
|
||||
this.artist = artist;
|
||||
this.album = album;
|
||||
this.callback = callback != null ? callback : (info, url) -> { };
|
||||
this.desiredSize = size;
|
||||
this.id = (artist + album + size.name).hashCode();
|
||||
|
||||
synchronized (this) {
|
||||
this.url = URL_CACHE.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
this.callback = (info, url) -> { };
|
||||
}
|
||||
|
||||
public boolean is(String artist, String album) {
|
||||
return (this.artist != null && this.artist.equalsIgnoreCase(artist) &&
|
||||
this.album != null && this.album.equalsIgnoreCase(album));
|
||||
}
|
||||
|
||||
public String getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
public interface AlbumArtCallback {
|
||||
void onFinished(final AlbumArtModel info, final String url);
|
||||
}
|
||||
|
||||
public synchronized String getUrl() {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public synchronized AlbumArtModel fetch() {
|
||||
if (this.fetching || this.noart) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!Strings.empty(this.url)) {
|
||||
callback.onFinished(this, this.url);
|
||||
}
|
||||
else if (Strings.notEmpty(this.artist) && Strings.notEmpty(this.album)) {
|
||||
String requestUrl;
|
||||
|
||||
try {
|
||||
final String sanitizedAlbum = removeKnownJunkFromMetadata(album);
|
||||
final String sanitizedArtist = removeKnownJunkFromMetadata(artist);
|
||||
|
||||
requestUrl = String.format(
|
||||
LAST_FM_ALBUM_INFO,
|
||||
URLEncoder.encode(sanitizedArtist, "UTF-8"),
|
||||
URLEncoder.encode(sanitizedAlbum, "UTF-8"));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
|
||||
this.fetching = true;
|
||||
final Request request = new Request.Builder().url(requestUrl).build();
|
||||
|
||||
OK_HTTP.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
fetching = false;
|
||||
callback.onFinished(AlbumArtModel.this, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
synchronized (AlbumArtModel.this) {
|
||||
List<Image> imageList = new ArrayList<>();
|
||||
|
||||
try {
|
||||
final JSONObject json = new JSONObject(response.body().string());
|
||||
final JSONArray imagesJson = json.getJSONObject("album").getJSONArray("image");
|
||||
for (int i = 0; i < imagesJson.length(); i++) {
|
||||
final JSONObject imageJson = imagesJson.getJSONObject(i);
|
||||
final Size size = Size.from(imageJson.optString("size", ""));
|
||||
if (size != null) {
|
||||
final String resolvedUrl = imageJson.optString("#text", "");
|
||||
if (Strings.notEmpty(resolvedUrl)) {
|
||||
imageList.add(new Image(size, resolvedUrl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageList.size() > 0) {
|
||||
/* find the image with the closest to the requested size.
|
||||
exact match preferred. */
|
||||
Size desiredSize = Size.Mega;
|
||||
Image closest = imageList.get(0);
|
||||
int lastDelta = Integer.MAX_VALUE;
|
||||
for (final Image check : imageList) {
|
||||
if (check.size == desiredSize) {
|
||||
closest = check;
|
||||
break;
|
||||
}
|
||||
else {
|
||||
int delta = Math.abs(desiredSize.order - check.size.order);
|
||||
if (lastDelta > delta) {
|
||||
closest = check;
|
||||
lastDelta = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized (AlbumArtModel.this) {
|
||||
URL_CACHE.put(id, closest.url);
|
||||
}
|
||||
|
||||
fetching = false;
|
||||
AlbumArtModel.this.url = closest.url;
|
||||
callback.onFinished(AlbumArtModel.this, closest.url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
}
|
||||
|
||||
noart = true; /* got a response, but it was invalid. we won't try again */
|
||||
fetching = false;
|
||||
}
|
||||
|
||||
callback.onFinished(AlbumArtModel.this, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
callback.onFinished(this, null);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static final Pattern[] BAD_PATTERNS = {
|
||||
Pattern.compile("(?i)^" + Pattern.quote("[") + "CD" + Pattern.quote("]")),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "disc \\d*" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "disc \\d*" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(+") + "video" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "video" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "explicit" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "explicit" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "digital booklet" + Pattern.quote("]") + "$")
|
||||
};
|
||||
|
||||
private static String removeKnownJunkFromMetadata(final String album) {
|
||||
String result = album;
|
||||
for (Pattern pattern : BAD_PATTERNS) {
|
||||
result = pattern.matcher(result).replaceAll("");
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
}
|
@ -0,0 +1,229 @@
|
||||
package io.casey.musikcube.remote.ui.model
|
||||
|
||||
import android.util.LruCache
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import okhttp3.*
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class AlbumArtModel(val track: String,
|
||||
private val artist: String = "",
|
||||
private val album: String = "",
|
||||
desiredSize: AlbumArtModel.Size,
|
||||
callback: AlbumArtModel.AlbumArtCallback?)
|
||||
{
|
||||
private var callback: AlbumArtCallback? = null
|
||||
private var fetching: Boolean = false
|
||||
private var noart: Boolean = false
|
||||
|
||||
val id: Int
|
||||
|
||||
@get:Synchronized var url: String? = null
|
||||
private set
|
||||
|
||||
enum class Size constructor(internal val key: String, internal val order: Int) {
|
||||
Small("small", 0),
|
||||
Medium("medium", 1),
|
||||
Large("large", 2),
|
||||
ExtraLarge("extralarge", 3),
|
||||
Mega("mega", 4);
|
||||
|
||||
companion object {
|
||||
internal fun from(value: String): Size? {
|
||||
return values().find { it.key == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Image(internal val size: Size, internal val url: String)
|
||||
|
||||
init {
|
||||
this.callback = callback ?: DEFAULT_CALLBACK
|
||||
this.id = (artist + album + desiredSize.key).hashCode()
|
||||
|
||||
synchronized(this) {
|
||||
this.url = URL_CACHE.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
this.callback = DEFAULT_CALLBACK
|
||||
}
|
||||
|
||||
fun matches(artist: String, album: String): Boolean {
|
||||
return this.artist.equals(artist, ignoreCase = true) &&
|
||||
this.album.equals(album, ignoreCase = true)
|
||||
}
|
||||
|
||||
interface AlbumArtCallback { /* TODO: remove this after converting everything to Kotlin */
|
||||
fun onFinished(model: AlbumArtModel, url: String?)
|
||||
}
|
||||
|
||||
@Synchronized fun fetch(): AlbumArtModel {
|
||||
if (this.fetching || this.noart) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (!Strings.empty(this.url)) {
|
||||
callback?.onFinished(this, this.url)
|
||||
}
|
||||
else if (Strings.notEmpty(this.artist) && Strings.notEmpty(this.album)) {
|
||||
val requestUrl: String
|
||||
|
||||
try {
|
||||
val sanitizedAlbum = removeKnownJunkFromMetadata(album)
|
||||
val sanitizedArtist = removeKnownJunkFromMetadata(artist)
|
||||
|
||||
requestUrl = String.format(
|
||||
LAST_FM_ALBUM_INFO,
|
||||
URLEncoder.encode(sanitizedArtist, "UTF-8"),
|
||||
URLEncoder.encode(sanitizedAlbum, "UTF-8"))
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
throw RuntimeException(ex)
|
||||
}
|
||||
|
||||
this.fetching = true
|
||||
val request = Request.Builder().url(requestUrl).build()
|
||||
|
||||
OK_HTTP.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
fetching = false
|
||||
callback?.onFinished(this@AlbumArtModel, null)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
synchronized(this@AlbumArtModel) {
|
||||
val imageList = ArrayList<Image>()
|
||||
|
||||
try {
|
||||
val json = JSONObject(response.body()?.string())
|
||||
val imagesJson = json.getJSONObject("album").getJSONArray("image")
|
||||
for (i in 0..imagesJson.length() - 1) {
|
||||
val imageJson = imagesJson.getJSONObject(i)
|
||||
val size = Size.from(imageJson.optString("size", ""))
|
||||
if (size != null) {
|
||||
val resolvedUrl = imageJson.optString("#text", "")
|
||||
if (Strings.notEmpty(resolvedUrl)) {
|
||||
imageList.add(Image(size, resolvedUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageList.size > 0) {
|
||||
/* find the image with the closest to the requested size.
|
||||
exact match preferred. */
|
||||
val desiredSize = Size.Mega
|
||||
var closest = imageList[0]
|
||||
var lastDelta = Integer.MAX_VALUE
|
||||
for (check in imageList) {
|
||||
if (check.size == desiredSize) {
|
||||
closest = check
|
||||
break
|
||||
}
|
||||
else {
|
||||
val delta = Math.abs(desiredSize.order - check.size.order)
|
||||
if (lastDelta > delta) {
|
||||
closest = check
|
||||
lastDelta = delta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(this@AlbumArtModel) {
|
||||
URL_CACHE.put(id, closest.url)
|
||||
}
|
||||
|
||||
fetching = false
|
||||
this@AlbumArtModel.url = closest.url
|
||||
callback?.onFinished(this@AlbumArtModel, closest.url)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
}
|
||||
|
||||
noart = true /* got a response, but it was invalid. we won't try again */
|
||||
fetching = false
|
||||
}
|
||||
|
||||
callback?.onFinished(this@AlbumArtModel, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
callback?.onFinished(this, null)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/* http://www.last.fm/group/Last.fm+Web+Services/forum/21604/_/522900 -- it's ok to
|
||||
put our key in the code */
|
||||
private val LAST_FM_ALBUM_INFO =
|
||||
"http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" +
|
||||
"502c69bd3f9946e8e0beee4fcb28c4cd&artist=%s&album=%s&format=json"
|
||||
|
||||
private var OK_HTTP: OkHttpClient
|
||||
private val URL_CACHE = LruCache<Int, String>(500)
|
||||
|
||||
private val DEFAULT_CALLBACK = object : AlbumArtCallback {
|
||||
override fun onFinished(model: AlbumArtModel, url: String?) {
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
OK_HTTP = OkHttpClient.Builder()
|
||||
.addInterceptor { chain: Interceptor.Chain ->
|
||||
val request = chain.request()
|
||||
var count = 0
|
||||
var result: Response? = null
|
||||
while (count++ < 3) {
|
||||
try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) {
|
||||
result = response
|
||||
break
|
||||
}
|
||||
}
|
||||
catch (ex: SocketTimeoutException) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
result ?: throw IOException("retries exhausted")
|
||||
}
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun empty(): AlbumArtModel {
|
||||
return AlbumArtModel("", "", "", Size.Small, null)
|
||||
}
|
||||
|
||||
private val BAD_PATTERNS = arrayOf(
|
||||
Pattern.compile("(?i)^" + Pattern.quote("[") + "CD" + Pattern.quote("]")),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "disc \\d*" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "disc \\d*" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(+") + "video" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "video" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("(") + "explicit" + Pattern.quote(")") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[") + "explicit" + Pattern.quote("]") + "$"),
|
||||
Pattern.compile("(?i)" + Pattern.quote("[+") + "digital booklet" + Pattern.quote("]") + "$"))
|
||||
|
||||
private fun removeKnownJunkFromMetadata(album: String): String {
|
||||
var result = album
|
||||
for (pattern in BAD_PATTERNS) {
|
||||
result = pattern.matcher(result).replaceAll("")
|
||||
}
|
||||
return result.trim { it <= ' ' }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,293 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.model;
|
||||
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class TrackListSlidingWindow<TrackType> {
|
||||
private static final int MAX_SIZE = 150;
|
||||
public static final int DEFAULT_WINDOW_SIZE = 75;
|
||||
|
||||
private int count = 0;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerFastScroller fastScroller;
|
||||
private WebSocketService wss;
|
||||
private Mapper<TrackType> mapper;
|
||||
private QueryFactory queryFactory;
|
||||
private int scrollState = RecyclerView.SCROLL_STATE_IDLE;
|
||||
private boolean fastScrollActive = false;
|
||||
private int queryOffset = -1, queryLimit = -1;
|
||||
private int initialPosition = -1;
|
||||
private int windowSize = DEFAULT_WINDOW_SIZE;
|
||||
private OnMetadataLoadedListener loadedListener;
|
||||
boolean connected = false;
|
||||
|
||||
private static class CacheEntry<TrackType> {
|
||||
TrackType value;
|
||||
boolean dirty;
|
||||
}
|
||||
|
||||
private Map<Integer, CacheEntry<TrackType>> cache = new LinkedHashMap<Integer, CacheEntry<TrackType>>() {
|
||||
protected boolean removeEldestEntry(Map.Entry<Integer, CacheEntry<TrackType>> eldest) {
|
||||
return size() >= MAX_SIZE;
|
||||
}
|
||||
};
|
||||
|
||||
public interface Mapper<TrackType> {
|
||||
TrackType map(final JSONObject track);
|
||||
}
|
||||
|
||||
public interface OnMetadataLoadedListener {
|
||||
void onMetadataLoaded(int offset, int count);
|
||||
void onReloaded(int count);
|
||||
}
|
||||
|
||||
public static abstract class QueryFactory {
|
||||
public abstract SocketMessage getRequeryMessage();
|
||||
public abstract SocketMessage getPageAroundMessage(int offset, int limit);
|
||||
|
||||
public boolean connectionRequired() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public TrackListSlidingWindow(RecyclerView recyclerView,
|
||||
RecyclerFastScroller fastScroller,
|
||||
WebSocketService wss,
|
||||
QueryFactory queryFactory,
|
||||
Mapper<TrackType> mapper) {
|
||||
this.recyclerView = recyclerView;
|
||||
this.fastScroller = fastScroller;
|
||||
this.wss = wss;
|
||||
this.queryFactory = queryFactory;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private View.OnTouchListener fastScrollerTouch = (view, event) -> {
|
||||
if (event != null) {
|
||||
final int type = event.getActionMasked();
|
||||
if (type == MotionEvent.ACTION_DOWN) {
|
||||
fastScrollActive = true;
|
||||
}
|
||||
else if (type == MotionEvent.ACTION_UP) {
|
||||
fastScrollActive = false;
|
||||
requery();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
public void requery() {
|
||||
boolean connectionRequired = (queryFactory != null) && queryFactory.connectionRequired();
|
||||
|
||||
if (!connectionRequired || connected) {
|
||||
cancelMessages();
|
||||
|
||||
boolean queried = false;
|
||||
|
||||
if (queryFactory != null) {
|
||||
final SocketMessage message = queryFactory.getRequeryMessage();
|
||||
|
||||
if (message != null) {
|
||||
wss.send(message, this.client, (SocketMessage response) -> {
|
||||
final int count = response.getIntOption(Messages.Key.COUNT, 0);
|
||||
setCount(count);
|
||||
|
||||
if (initialPosition != -1 && recyclerView != null) {
|
||||
recyclerView.scrollToPosition(initialPosition);
|
||||
initialPosition = -1;
|
||||
}
|
||||
|
||||
if (loadedListener != null) {
|
||||
loadedListener.onReloaded(count);
|
||||
}
|
||||
});
|
||||
|
||||
queried = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!queried) {
|
||||
setCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void pause() {
|
||||
connected = false;
|
||||
this.wss.removeClient(this.client);
|
||||
|
||||
if (this.recyclerView != null) {
|
||||
this.recyclerView.removeOnScrollListener(scrollListener);
|
||||
}
|
||||
|
||||
if (this.fastScroller != null) {
|
||||
fastScroller.setOnHandleTouchListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
public void resume() {
|
||||
if (this.recyclerView != null) {
|
||||
this.recyclerView.addOnScrollListener(scrollListener);
|
||||
}
|
||||
|
||||
if (this.fastScroller != null) {
|
||||
fastScroller.setOnHandleTouchListener(fastScrollerTouch);
|
||||
}
|
||||
|
||||
this.wss.addClient(this.client);
|
||||
connected = true;
|
||||
fastScrollActive = false;
|
||||
}
|
||||
|
||||
public void setInitialPosition(int initialIndex) {
|
||||
this.initialPosition = initialIndex;
|
||||
}
|
||||
|
||||
public void setOnMetadataLoadedListener(OnMetadataLoadedListener loadedListener) {
|
||||
this.loadedListener = loadedListener;
|
||||
}
|
||||
|
||||
public void setWindowSize(int windowSize) {
|
||||
this.windowSize = windowSize;
|
||||
}
|
||||
|
||||
public void setCount(int count) {
|
||||
this.count = count;
|
||||
invalidateCache();
|
||||
cancelMessages();
|
||||
notifyAdapterChanged();
|
||||
notifyMetadataLoaded(0, 0);
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public TrackType getTrack(int index) {
|
||||
final CacheEntry<TrackType> track = cache.get(index);
|
||||
|
||||
if (track == null || track.dirty) {
|
||||
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
this.getPageAround(index);
|
||||
}
|
||||
}
|
||||
|
||||
return (track == null) ? null : track.value;
|
||||
}
|
||||
|
||||
private void invalidateCache() {
|
||||
for (final CacheEntry<TrackType> entry : cache.values()) {
|
||||
entry.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelMessages() {
|
||||
this.queryOffset = this.queryLimit = -1;
|
||||
this.wss.cancelMessages(this.client);
|
||||
}
|
||||
|
||||
private void getPageAround(int index) {
|
||||
if (!connected || scrolling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= queryOffset && index <= queryOffset + queryLimit) {
|
||||
return; /* already in flight */
|
||||
}
|
||||
|
||||
int offset = Math.max(0, index - 10); /* snag a couple before */
|
||||
int limit = windowSize;
|
||||
|
||||
SocketMessage request = this.queryFactory.getPageAroundMessage(offset, limit);
|
||||
|
||||
if (request != null) {
|
||||
cancelMessages();
|
||||
|
||||
queryOffset = offset;
|
||||
queryLimit = limit;
|
||||
|
||||
this.wss.send(request, this.client, (SocketMessage response) -> {
|
||||
this.queryOffset = this.queryLimit = -1;
|
||||
|
||||
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
|
||||
final int responseOffset = response.getIntOption(Messages.Key.OFFSET);
|
||||
if (data != null) {
|
||||
for (int i = 0; i < data.length(); i++) {
|
||||
final JSONObject track = data.optJSONObject(i);
|
||||
if (track != null) {
|
||||
final CacheEntry<TrackType> entry = new CacheEntry<>();
|
||||
entry.dirty = false;
|
||||
entry.value = mapper.map(track);
|
||||
cache.put(responseOffset + i, entry);
|
||||
}
|
||||
}
|
||||
|
||||
notifyAdapterChanged();
|
||||
notifyMetadataLoaded(responseOffset, data.length());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyAdapterChanged() {
|
||||
if (this.recyclerView != null) {
|
||||
final RecyclerView.Adapter<?> adapter = this.recyclerView.getAdapter();
|
||||
if (adapter != null) {
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyMetadataLoaded(int offset, int count) {
|
||||
if (this.loadedListener != null) {
|
||||
this.loadedListener.onMetadataLoaded(offset, count);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean scrolling() {
|
||||
return scrollState != RecyclerView.SCROLL_STATE_IDLE || fastScrollActive;
|
||||
}
|
||||
|
||||
private RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
|
||||
scrollState = newState;
|
||||
if (!scrolling()) {
|
||||
notifyAdapterChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private WebSocketService.Client client = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
if (message.getType() == SocketMessage.Type.Broadcast) {
|
||||
if (Messages.Broadcast.PlayQueueChanged.is(message.getName())) {
|
||||
requery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,245 @@
|
||||
package io.casey.musikcube.remote.ui.model
|
||||
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import org.json.JSONObject
|
||||
import java.util.*
|
||||
|
||||
class TrackListSlidingWindow(private val _recyclerView: RecyclerView,
|
||||
private val _fastScroller: RecyclerFastScroller,
|
||||
private val _wss: WebSocketService,
|
||||
private val _queryFactory: TrackListSlidingWindow.QueryFactory?) {
|
||||
private var _scrollState = RecyclerView.SCROLL_STATE_IDLE
|
||||
private var _fastScrollActive = false
|
||||
private var _queryOffset = -1
|
||||
private var _queryLimit = -1
|
||||
private var _initialPosition = -1
|
||||
private var _windowSize = DEFAULT_WINDOW_SIZE
|
||||
private var _loadedListener: OnMetadataLoadedListener? = null
|
||||
internal var _connected = false
|
||||
|
||||
private class CacheEntry {
|
||||
internal var value: JSONObject? = null
|
||||
internal var dirty: Boolean = false
|
||||
}
|
||||
|
||||
private val cache = object : LinkedHashMap<Int, CacheEntry>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, CacheEntry>): Boolean {
|
||||
return size >= MAX_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
interface OnMetadataLoadedListener {
|
||||
fun onMetadataLoaded(offset: Int, count: Int)
|
||||
fun onReloaded(count: Int)
|
||||
}
|
||||
|
||||
abstract class QueryFactory {
|
||||
abstract fun getRequeryMessage(): SocketMessage?
|
||||
abstract fun getPageAroundMessage(offset: Int, limit: Int): SocketMessage?
|
||||
|
||||
open fun connectionRequired(): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var count = 0
|
||||
set(count) {
|
||||
field = count
|
||||
invalidateCache()
|
||||
cancelMessages()
|
||||
notifyAdapterChanged()
|
||||
notifyMetadataLoaded(0, 0)
|
||||
}
|
||||
|
||||
private val _fastScrollerTouch by lazy {
|
||||
View.OnTouchListener { _, event ->
|
||||
if (event != null) {
|
||||
val type = event.actionMasked
|
||||
if (type == MotionEvent.ACTION_DOWN) {
|
||||
_fastScrollActive = true
|
||||
}
|
||||
else if (type == MotionEvent.ACTION_UP) {
|
||||
_fastScrollActive = false
|
||||
requery()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun requery() {
|
||||
val connectionRequired = _queryFactory != null && _queryFactory.connectionRequired()
|
||||
|
||||
if (!connectionRequired || _connected) {
|
||||
cancelMessages()
|
||||
|
||||
var queried = false
|
||||
|
||||
if (_queryFactory != null) {
|
||||
val message = _queryFactory.getRequeryMessage()
|
||||
|
||||
if (message != null) {
|
||||
_wss.send(message, _client) { response: SocketMessage ->
|
||||
count = response.getIntOption(Messages.Key.COUNT, 0)
|
||||
|
||||
if (_initialPosition != -1) {
|
||||
_recyclerView.scrollToPosition(_initialPosition)
|
||||
_initialPosition = -1
|
||||
}
|
||||
|
||||
_loadedListener?.onReloaded(count)
|
||||
}
|
||||
|
||||
queried = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!queried) {
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
_connected = false
|
||||
_wss.removeClient(_client)
|
||||
_recyclerView.removeOnScrollListener(_scrollListener)
|
||||
_fastScroller.setOnHandleTouchListener(null)
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
_recyclerView.addOnScrollListener(_scrollListener)
|
||||
_fastScroller.setOnHandleTouchListener(_fastScrollerTouch)
|
||||
_wss.addClient(_client)
|
||||
_connected = true
|
||||
_fastScrollActive = false
|
||||
}
|
||||
|
||||
fun setInitialPosition(initialIndex: Int) {
|
||||
_initialPosition = initialIndex
|
||||
}
|
||||
|
||||
fun setOnMetadataLoadedListener(loadedListener: OnMetadataLoadedListener) {
|
||||
_loadedListener = loadedListener
|
||||
}
|
||||
|
||||
fun setWindowSize(windowSize: Int) {
|
||||
_windowSize = windowSize
|
||||
}
|
||||
|
||||
fun getTrack(index: Int): JSONObject? {
|
||||
val track = cache[index]
|
||||
|
||||
if (track?.dirty ?: true) {
|
||||
if (_scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
getPageAround(index)
|
||||
}
|
||||
}
|
||||
|
||||
return track?.value
|
||||
}
|
||||
|
||||
private fun invalidateCache() {
|
||||
for (entry in cache.values) {
|
||||
entry.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelMessages() {
|
||||
_queryLimit = -1
|
||||
_queryOffset = _queryLimit
|
||||
_wss.cancelMessages(_client)
|
||||
}
|
||||
|
||||
private fun getPageAround(index: Int) {
|
||||
if (!_connected || scrolling()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (index >= _queryOffset && index <= _queryOffset + _queryLimit) {
|
||||
return /* already in flight */
|
||||
}
|
||||
|
||||
val offset = Math.max(0, index - 10) /* snag a couple before */
|
||||
val limit = _windowSize
|
||||
|
||||
val request = _queryFactory!!.getPageAroundMessage(offset, limit)
|
||||
|
||||
if (request != null) {
|
||||
cancelMessages()
|
||||
|
||||
_queryOffset = offset
|
||||
_queryLimit = limit
|
||||
|
||||
_wss.send(request, _client) { response: SocketMessage ->
|
||||
_queryLimit = -1
|
||||
_queryOffset = _queryLimit
|
||||
|
||||
val data = response.getJsonArrayOption(Messages.Key.DATA)
|
||||
val responseOffset = response.getIntOption(Messages.Key.OFFSET)
|
||||
if (data != null) {
|
||||
for (i in 0..data.length() - 1) {
|
||||
val track = data.optJSONObject(i)
|
||||
if (track != null) {
|
||||
val entry = CacheEntry()
|
||||
entry.dirty = false
|
||||
entry.value = track
|
||||
cache.put(responseOffset + i, entry)
|
||||
}
|
||||
}
|
||||
|
||||
notifyAdapterChanged()
|
||||
notifyMetadataLoaded(responseOffset, data.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyAdapterChanged() {
|
||||
_recyclerView.adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun notifyMetadataLoaded(offset: Int, count: Int) {
|
||||
_loadedListener?.onMetadataLoaded(offset, count)
|
||||
}
|
||||
|
||||
private fun scrolling(): Boolean {
|
||||
return _scrollState != RecyclerView.SCROLL_STATE_IDLE || _fastScrollActive
|
||||
}
|
||||
|
||||
private val _scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
|
||||
_scrollState = newState
|
||||
if (!scrolling()) {
|
||||
notifyAdapterChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _client = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {
|
||||
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: SocketMessage) {
|
||||
if (message.type == SocketMessage.Type.Broadcast) {
|
||||
if (Messages.Broadcast.PlayQueueChanged.matches(message.name)) {
|
||||
requery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MAX_SIZE = 150
|
||||
val DEFAULT_WINDOW_SIZE = 75
|
||||
}
|
||||
}
|
@ -1,150 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.util;
|
||||
|
||||
import android.app.SearchManager;
|
||||
import android.app.SearchableInfo;
|
||||
import android.content.Context;
|
||||
import android.support.v4.view.MenuItemCompat;
|
||||
import android.support.v7.app.ActionBar;
|
||||
import android.support.v7.app.AppCompatActivity;
|
||||
import android.support.v7.widget.DividerItemDecoration;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.support.v7.widget.SearchView;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
|
||||
import com.pluscubed.recyclerfastscroll.RecyclerFastScroller;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.ui.activity.Filterable;
|
||||
import io.casey.musikcube.remote.ui.fragment.TransportFragment;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
|
||||
public final class Views {
|
||||
public static String EXTRA_TITLE = "extra_title";
|
||||
|
||||
public static void setCheckWithoutEvent(final CheckBox cb,
|
||||
final boolean checked,
|
||||
final CheckBox.OnCheckedChangeListener listener) {
|
||||
cb.setOnCheckedChangeListener(null);
|
||||
cb.setChecked(checked);
|
||||
cb.setOnCheckedChangeListener(listener);
|
||||
}
|
||||
|
||||
public static void setupDefaultRecyclerView(final Context context,
|
||||
final RecyclerView recyclerView,
|
||||
final RecyclerFastScroller fastScroller,
|
||||
final RecyclerView.Adapter adapter) {
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(context);
|
||||
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(context));
|
||||
recyclerView.setAdapter(adapter);
|
||||
fastScroller.attachRecyclerView(recyclerView);
|
||||
|
||||
final DividerItemDecoration dividerItemDecoration =
|
||||
new DividerItemDecoration(context, layoutManager.getOrientation());
|
||||
|
||||
recyclerView.addItemDecoration(dividerItemDecoration);
|
||||
}
|
||||
|
||||
public static TransportFragment addTransportFragment(final AppCompatActivity activity) {
|
||||
return addTransportFragment(activity, null);
|
||||
}
|
||||
|
||||
public static TransportFragment addTransportFragment(
|
||||
final AppCompatActivity activity, TransportFragment.OnModelChangedListener listener)
|
||||
{
|
||||
final View root = activity.findViewById(android.R.id.content);
|
||||
if (root != null) {
|
||||
if (root.findViewById(R.id.transport_container) != null) {
|
||||
final TransportFragment fragment = TransportFragment.newInstance();
|
||||
|
||||
activity.getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(R.id.transport_container, fragment, TransportFragment.TAG)
|
||||
.commit();
|
||||
|
||||
fragment.setModelChangedListener(listener);
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void initSearchMenu(final AppCompatActivity activity,
|
||||
final Menu menu,
|
||||
final Filterable filterable) {
|
||||
activity.getMenuInflater().inflate(R.menu.search_menu, menu);
|
||||
|
||||
final MenuItem searchMenuItem = menu.findItem(R.id.action_search);
|
||||
|
||||
final SearchView searchView =
|
||||
(SearchView) MenuItemCompat .getActionView(searchMenuItem);
|
||||
|
||||
searchView.setMaxWidth(Integer.MAX_VALUE);
|
||||
|
||||
if (filterable != null) {
|
||||
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextChange(String newText) {
|
||||
filterable.setFilter(newText);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
searchView.setOnCloseListener(() -> {
|
||||
filterable.setFilter("");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
final SearchManager searchManager = (SearchManager)
|
||||
activity.getSystemService(Context.SEARCH_SERVICE);
|
||||
|
||||
final SearchableInfo searchableInfo = searchManager
|
||||
.getSearchableInfo(activity.getComponentName());
|
||||
|
||||
searchView.setSearchableInfo(searchableInfo);
|
||||
searchView.setIconifiedByDefault(true);
|
||||
}
|
||||
|
||||
public static void setTextAndMoveCursorToEnd(final EditText editText, final String text) {
|
||||
editText.setText(text);
|
||||
editText.setSelection(editText.getText().length());
|
||||
}
|
||||
|
||||
public static void enableUpNavigation(final AppCompatActivity activity) {
|
||||
final ActionBar ab = activity.getSupportActionBar();
|
||||
if (ab != null) {
|
||||
ab.setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setTitle(final AppCompatActivity activity, int defaultId) {
|
||||
final String title = activity.getIntent().getStringExtra(EXTRA_TITLE);
|
||||
if (Strings.notEmpty(title)) {
|
||||
activity.setTitle(title);
|
||||
}
|
||||
else {
|
||||
activity.setTitle(defaultId);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setVisible(View view, boolean visible) {
|
||||
if (view != null) {
|
||||
view.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private Views() {
|
||||
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory
|
||||
import io.casey.musikcube.remote.playback.StreamingPlaybackService
|
||||
import io.casey.musikcube.remote.ui.activity.TrackListActivity
|
||||
import io.casey.musikcube.remote.ui.util.Views
|
||||
import io.casey.musikcube.remote.ui.extension.*
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService.State as WebSocketState
|
||||
|
||||
@ -73,8 +73,8 @@ class EmptyListView : FrameLayout {
|
||||
PlaybackServiceFactory.instance(context) is StreamingPlaybackService &&
|
||||
state != WebSocketState.Connected
|
||||
|
||||
Views.setVisible(offlineContainer, showOfflineContainer)
|
||||
Views.setVisible(emptyContainer, !showOfflineContainer)
|
||||
offlineContainer?.setVisible(showOfflineContainer)
|
||||
emptyContainer?.setVisible(!showOfflineContainer)
|
||||
}
|
||||
|
||||
alternateView?.visibility = if (visibility == View.GONE) View.VISIBLE else View.GONE
|
||||
|
@ -1,393 +0,0 @@
|
||||
package io.casey.musikcube.remote.ui.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.os.Handler;
|
||||
import android.support.annotation.AttrRes;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextPaint;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.drawable.GlideDrawable;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.casey.musikcube.remote.R;
|
||||
import io.casey.musikcube.remote.playback.Metadata;
|
||||
import io.casey.musikcube.remote.playback.PlaybackService;
|
||||
import io.casey.musikcube.remote.playback.PlaybackServiceFactory;
|
||||
import io.casey.musikcube.remote.playback.PlaybackState;
|
||||
import io.casey.musikcube.remote.playback.StreamingPlaybackService;
|
||||
import io.casey.musikcube.remote.ui.activity.AlbumBrowseActivity;
|
||||
import io.casey.musikcube.remote.ui.activity.TrackListActivity;
|
||||
import io.casey.musikcube.remote.ui.model.AlbumArtModel;
|
||||
import io.casey.musikcube.remote.util.Strings;
|
||||
import io.casey.musikcube.remote.websocket.Messages;
|
||||
import io.casey.musikcube.remote.websocket.Prefs;
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage;
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService;
|
||||
|
||||
public class MainMetadataView extends FrameLayout {
|
||||
private Handler handler = new Handler();
|
||||
private WebSocketService wss = null;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
private boolean isPaused = true;
|
||||
|
||||
private TextView title, artist, album, volume;
|
||||
private TextView titleWithArt, artistAndAlbumWithArt, volumeWithArt;
|
||||
private View mainTrackMetadataWithAlbumArt, mainTrackMetadataNoAlbumArt;
|
||||
private View buffering;
|
||||
private ImageView albumArtImageView;
|
||||
|
||||
private enum DisplayMode { Artwork, NoArtwork, Stopped }
|
||||
private AlbumArtModel albumArtModel = AlbumArtModel.empty();
|
||||
private DisplayMode lastDisplayMode = DisplayMode.Stopped;
|
||||
private String lastArtworkUrl = null;
|
||||
|
||||
public MainMetadataView(@NonNull Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public MainMetadataView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public MainMetadataView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
public void onResume() {
|
||||
this.wss.addClient(wssClient);
|
||||
isPaused = false;
|
||||
}
|
||||
|
||||
public void onPause() {
|
||||
this.wss.removeClient(wssClient);
|
||||
isPaused = true;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
if (!isPaused) {
|
||||
albumArtModel = AlbumArtModel.empty();
|
||||
updateAlbumArt();
|
||||
}
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (!isPaused) {
|
||||
setVisibility(View.VISIBLE);
|
||||
|
||||
final PlaybackService playback = getPlaybackService();
|
||||
|
||||
final boolean buffering = playback.getPlaybackState() == PlaybackState.Buffering;
|
||||
final boolean streaming = playback instanceof StreamingPlaybackService;
|
||||
|
||||
final String artist = playback.getTrackString(Metadata.Track.ARTIST, "");
|
||||
final String album = playback.getTrackString(Metadata.Track.ALBUM, "");
|
||||
final String title = playback.getTrackString(Metadata.Track.TITLE, "");
|
||||
|
||||
/* we don't display the volume amount when we're streaming -- the system has
|
||||
overlays for drawing volume. */
|
||||
if (streaming) {
|
||||
this.volume.setVisibility(View.GONE);
|
||||
this.volumeWithArt.setVisibility(View.GONE);
|
||||
}
|
||||
else {
|
||||
final String volume = getString(R.string.status_volume, Math.round(playback.getVolume() * 100));
|
||||
this.volume.setVisibility(View.VISIBLE);
|
||||
this.volumeWithArt.setVisibility(View.VISIBLE);
|
||||
this.volume.setText(volume);
|
||||
this.volumeWithArt.setText(volume);
|
||||
}
|
||||
|
||||
this.title.setText(Strings.empty(title) ? getString(buffering ? R.string.buffering : R.string.unknown_title) : title);
|
||||
this.artist.setText(Strings.empty(artist) ? getString(buffering ? R.string.buffering : R.string.unknown_artist) : artist);
|
||||
this.album.setText(Strings.empty(album) ? getString(buffering ? R.string.buffering : R.string.unknown_album) : album);
|
||||
|
||||
this.rebindAlbumArtistWithArtTextView(playback);
|
||||
this.titleWithArt.setText(Strings.empty(title) ? getString(buffering ? R.string.buffering : R.string.unknown_title) : title);
|
||||
|
||||
this.buffering.setVisibility(buffering ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean albumArtEnabledInSettings = this.prefs.getBoolean(
|
||||
Prefs.Key.ALBUM_ART_ENABLED, Prefs.Default.ALBUM_ART_ENABLED);
|
||||
|
||||
if (!albumArtEnabledInSettings || Strings.empty(artist) || Strings.empty(album)) {
|
||||
this.albumArtModel = AlbumArtModel.empty();
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
else {
|
||||
if (!this.albumArtModel.is(artist, album)) {
|
||||
this.albumArtModel.destroy();
|
||||
|
||||
this.albumArtModel = new AlbumArtModel(
|
||||
title, artist, album, AlbumArtModel.Size.Mega, albumArtRetrieved);
|
||||
}
|
||||
updateAlbumArt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PlaybackService getPlaybackService() {
|
||||
return PlaybackServiceFactory.instance(getContext());
|
||||
}
|
||||
|
||||
private String getString(int resId) {
|
||||
return getContext().getString(resId);
|
||||
}
|
||||
|
||||
private String getString(int resId, Object... args) {
|
||||
return getContext().getString(resId, args);
|
||||
}
|
||||
|
||||
private void setMetadataDisplayMode(DisplayMode mode) {
|
||||
lastDisplayMode = mode;
|
||||
|
||||
if (mode == DisplayMode.Stopped) {
|
||||
albumArtImageView.setImageDrawable(null);
|
||||
mainTrackMetadataWithAlbumArt.setVisibility(View.GONE);
|
||||
mainTrackMetadataNoAlbumArt.setVisibility(View.GONE);
|
||||
}
|
||||
else if (mode == DisplayMode.Artwork) {
|
||||
mainTrackMetadataWithAlbumArt.setVisibility(View.VISIBLE);
|
||||
mainTrackMetadataNoAlbumArt.setVisibility(View.GONE);
|
||||
}
|
||||
else {
|
||||
albumArtImageView.setImageDrawable(null);
|
||||
mainTrackMetadataWithAlbumArt.setVisibility(View.GONE);
|
||||
mainTrackMetadataNoAlbumArt.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void rebindAlbumArtistWithArtTextView(final PlaybackService playback) {
|
||||
final boolean buffering = playback.getPlaybackState() == PlaybackState.Buffering;
|
||||
|
||||
final String artist = playback.getTrackString(
|
||||
Metadata.Track.ARTIST, getString(buffering ? R.string.buffering : R.string.unknown_artist));
|
||||
|
||||
final String album = playback.getTrackString(
|
||||
Metadata.Track.ALBUM, getString(buffering ? R.string.buffering : R.string.unknown_album));
|
||||
|
||||
final ForegroundColorSpan albumColor =
|
||||
new ForegroundColorSpan(getResources().getColor(R.color.theme_orange));
|
||||
|
||||
final ForegroundColorSpan artistColor =
|
||||
new ForegroundColorSpan(getResources().getColor(R.color.theme_yellow));
|
||||
|
||||
final SpannableStringBuilder builder =
|
||||
new SpannableStringBuilder().append(album).append(" - ").append(artist);
|
||||
|
||||
final ClickableSpan albumClickable = new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(View widget) {
|
||||
navigateToCurrentAlbum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(TextPaint ds) {
|
||||
}
|
||||
};
|
||||
|
||||
final ClickableSpan artistClickable = new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(View widget) {
|
||||
navigateToCurrentArtist();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(TextPaint ds) {
|
||||
}
|
||||
};
|
||||
|
||||
int artistOffset = album.length() + 3;
|
||||
|
||||
builder.setSpan(albumColor, 0, album.length(), 0);
|
||||
builder.setSpan(albumClickable, 0, album.length(), 0);
|
||||
builder.setSpan(artistColor, artistOffset, artistOffset + artist.length(), 0);
|
||||
builder.setSpan(artistClickable, artistOffset, artistOffset + artist.length(), 0);
|
||||
|
||||
this.artistAndAlbumWithArt.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
this.artistAndAlbumWithArt.setHighlightColor(Color.TRANSPARENT);
|
||||
this.artistAndAlbumWithArt.setText(builder);
|
||||
}
|
||||
|
||||
private void updateAlbumArt() {
|
||||
if (getPlaybackService().getPlaybackState() == PlaybackState.Stopped) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
|
||||
final String url = albumArtModel.getUrl();
|
||||
|
||||
if (Strings.empty(url)) {
|
||||
this.lastArtworkUrl = null;
|
||||
albumArtModel.fetch();
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
else if (!url.equals(lastArtworkUrl) || lastDisplayMode == DisplayMode.Stopped) {
|
||||
final int loadId = albumArtModel.getId();
|
||||
this.lastArtworkUrl = url;
|
||||
|
||||
Glide.with(getContext())
|
||||
.load(url)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.listener(new RequestListener<String, GlideDrawable>() {
|
||||
@Override
|
||||
public boolean onException(Exception e, String model, Target<GlideDrawable> target, boolean first) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
lastArtworkUrl = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(GlideDrawable resource, String model, Target<GlideDrawable> target, boolean memory, boolean first) {
|
||||
if (!isPaused) {
|
||||
preloadNextImage();
|
||||
}
|
||||
|
||||
/* if the loadId doesn't match the current id, then the image was
|
||||
loaded for a different song. throw it away. */
|
||||
if (albumArtModel.getId() != loadId) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
setMetadataDisplayMode(DisplayMode.Artwork);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.into(albumArtImageView);
|
||||
}
|
||||
else {
|
||||
setMetadataDisplayMode(lastDisplayMode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void preloadNextImage() {
|
||||
final SocketMessage request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, getPlaybackService().getQueuePosition() + 1)
|
||||
.addOption(Messages.Key.LIMIT, 1)
|
||||
.build();
|
||||
|
||||
this.wss.send(request, wssClient, (response) -> {
|
||||
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA, new JSONArray());
|
||||
if (data.length() > 0) {
|
||||
JSONObject track = data.optJSONObject(0);
|
||||
final String artist = track.optString(Metadata.Track.ARTIST, "");
|
||||
final String album = track.optString(Metadata.Track.ALBUM, "");
|
||||
|
||||
if (!albumArtModel.is(artist, album)) {
|
||||
new AlbumArtModel("", artist, album, AlbumArtModel.Size.Mega, (info, url) -> {
|
||||
int width = albumArtImageView.getWidth();
|
||||
int height = albumArtImageView.getHeight();
|
||||
Glide.with(getContext()).load(url).downloadOnly(width, height);
|
||||
}).fetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void init() {
|
||||
this.prefs = getContext().getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
this.wss = WebSocketService.getInstance(getContext());
|
||||
|
||||
final View child = LayoutInflater.from(getContext())
|
||||
.inflate(R.layout.main_metadata, this, false);
|
||||
|
||||
addView(child, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
|
||||
|
||||
this.title = (TextView) findViewById(R.id.track_title);
|
||||
this.artist = (TextView) findViewById(R.id.track_artist);
|
||||
this.album = (TextView) findViewById(R.id.track_album);
|
||||
this.volume = (TextView) findViewById(R.id.volume);
|
||||
this.buffering = findViewById(R.id.buffering);
|
||||
|
||||
this.titleWithArt = (TextView) findViewById(R.id.with_art_track_title);
|
||||
this.artistAndAlbumWithArt = (TextView) findViewById(R.id.with_art_artist_and_album);
|
||||
this.volumeWithArt = (TextView) findViewById(R.id.with_art_volume);
|
||||
|
||||
this.mainTrackMetadataWithAlbumArt = findViewById(R.id.main_track_metadata_with_art);
|
||||
this.mainTrackMetadataNoAlbumArt = findViewById(R.id.main_track_metadata_without_art);
|
||||
this.albumArtImageView = (ImageView) findViewById(R.id.album_art);
|
||||
|
||||
this.album.setOnClickListener((view) -> navigateToCurrentAlbum());
|
||||
this.artist.setOnClickListener((view) -> navigateToCurrentArtist());
|
||||
}
|
||||
|
||||
private void navigateToCurrentArtist() {
|
||||
final Context context = getContext();
|
||||
final PlaybackService playback = getPlaybackService();
|
||||
|
||||
final long artistId = playback.getTrackLong(Metadata.Track.ARTIST_ID, -1);
|
||||
if (artistId != -1) {
|
||||
final String artistName = playback.getTrackString(Metadata.Track.ARTIST, "");
|
||||
context.startActivity(AlbumBrowseActivity.getStartIntent(
|
||||
context, Messages.Category.ARTIST, artistId, artistName));
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateToCurrentAlbum() {
|
||||
final Context context = getContext();
|
||||
final PlaybackService playback = getPlaybackService();
|
||||
|
||||
final long albumId = playback.getTrackLong(Metadata.Track.ALBUM_ID, -1);
|
||||
if (albumId != -1) {
|
||||
final String albumName = playback.getTrackString(Metadata.Track.ALBUM, "");
|
||||
context.startActivity(TrackListActivity.getStartIntent(
|
||||
context, Messages.Category.ALBUM, albumId, albumName));
|
||||
}
|
||||
}
|
||||
|
||||
private WebSocketService.Client wssClient = new WebSocketService.Client() {
|
||||
@Override
|
||||
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(SocketMessage message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidPassword() {
|
||||
}
|
||||
};
|
||||
|
||||
private AlbumArtModel.AlbumArtCallback albumArtRetrieved = (model, url) -> {
|
||||
handler.post(() -> {
|
||||
if (model == albumArtModel) {
|
||||
if (Strings.empty(model.getUrl())) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork);
|
||||
}
|
||||
else {
|
||||
updateAlbumArt();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
@ -0,0 +1,369 @@
|
||||
package io.casey.musikcube.remote.ui.view
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import android.support.annotation.AttrRes
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextPaint
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.drawable.GlideDrawable
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.playback.*
|
||||
import io.casey.musikcube.remote.ui.activity.AlbumBrowseActivity
|
||||
import io.casey.musikcube.remote.ui.activity.TrackListActivity
|
||||
import io.casey.musikcube.remote.ui.model.AlbumArtModel
|
||||
import io.casey.musikcube.remote.ui.model.AlbumArtModel.AlbumArtCallback
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import io.casey.musikcube.remote.websocket.Messages
|
||||
import io.casey.musikcube.remote.websocket.Prefs
|
||||
import io.casey.musikcube.remote.websocket.SocketMessage
|
||||
import io.casey.musikcube.remote.websocket.WebSocketService
|
||||
import org.json.JSONArray
|
||||
|
||||
class MainMetadataView : FrameLayout {
|
||||
private var wss: WebSocketService? = null
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
private var isPaused = true
|
||||
|
||||
private var title: TextView? = null
|
||||
private var artist: TextView? = null
|
||||
private var album: TextView? = null
|
||||
private var volume: TextView? = null
|
||||
private var titleWithArt: TextView? = null
|
||||
private var artistAndAlbumWithArt: TextView? = null
|
||||
private var volumeWithArt: TextView? = null
|
||||
private var mainTrackMetadataWithAlbumArt: View? = null
|
||||
private var mainTrackMetadataNoAlbumArt: View? = null
|
||||
private var buffering: View? = null
|
||||
private var albumArtImageView: ImageView? = null
|
||||
|
||||
private enum class DisplayMode {
|
||||
Artwork, NoArtwork, Stopped
|
||||
}
|
||||
|
||||
private var albumArtModel = AlbumArtModel.empty()
|
||||
private var lastDisplayMode = DisplayMode.Stopped
|
||||
private var lastArtworkUrl: String? = null
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
||||
init()
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
this.wss?.addClient(wssClient)
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
this.wss?.removeClient(wssClient)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
if (!isPaused) {
|
||||
albumArtModel = AlbumArtModel.empty()
|
||||
updateAlbumArt()
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (!isPaused) {
|
||||
visibility = View.VISIBLE
|
||||
|
||||
val playback = playbackService
|
||||
|
||||
val buffering = playback.playbackState == PlaybackState.Buffering
|
||||
val streaming = playback is StreamingPlaybackService
|
||||
|
||||
val artist = playback.getTrackString(Metadata.Track.ARTIST, "")
|
||||
val album = playback.getTrackString(Metadata.Track.ALBUM, "")
|
||||
val title = playback.getTrackString(Metadata.Track.TITLE, "")
|
||||
|
||||
/* we don't display the volume amount when we're streaming -- the system has
|
||||
overlays for drawing volume. */
|
||||
if (streaming) {
|
||||
this.volume?.visibility = View.GONE
|
||||
this.volumeWithArt?.visibility = View.GONE
|
||||
}
|
||||
else {
|
||||
val volume = getString(R.string.status_volume, Math.round(playback.volume * 100))
|
||||
this.volume?.visibility = View.VISIBLE
|
||||
this.volumeWithArt?.visibility = View.VISIBLE
|
||||
this.volume?.text = volume
|
||||
this.volumeWithArt?.text = volume
|
||||
}
|
||||
|
||||
this.title?.text = if (Strings.empty(title)) getString(if (buffering) R.string.buffering else R.string.unknown_title) else title
|
||||
this.artist?.text = if (Strings.empty(artist)) getString(if (buffering) R.string.buffering else R.string.unknown_artist) else artist
|
||||
this.album?.text = if (Strings.empty(album)) getString(if (buffering) R.string.buffering else R.string.unknown_album) else album
|
||||
|
||||
this.rebindAlbumArtistWithArtTextView(playback)
|
||||
this.titleWithArt?.text = if (Strings.empty(title)) getString(if (buffering) R.string.buffering else R.string.unknown_title) else title
|
||||
this.buffering?.visibility = if (buffering) View.VISIBLE else View.GONE
|
||||
|
||||
val albumArtEnabledInSettings = this.prefs?.getBoolean(
|
||||
Prefs.Key.ALBUM_ART_ENABLED, Prefs.Default.ALBUM_ART_ENABLED) ?: false
|
||||
|
||||
if (!albumArtEnabledInSettings || Strings.empty(artist) || Strings.empty(album)) {
|
||||
this.albumArtModel = AlbumArtModel.empty()
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork)
|
||||
}
|
||||
else {
|
||||
if (!this.albumArtModel.matches(artist, album)) {
|
||||
this.albumArtModel.destroy()
|
||||
|
||||
this.albumArtModel = AlbumArtModel(
|
||||
title, artist, album, AlbumArtModel.Size.Mega, albumArtRetrieved)
|
||||
}
|
||||
updateAlbumArt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val playbackService: PlaybackService
|
||||
get() = PlaybackServiceFactory.instance(context)
|
||||
|
||||
private fun getString(resId: Int): String {
|
||||
return context.getString(resId)
|
||||
}
|
||||
|
||||
private fun getString(resId: Int, vararg args: Any): String {
|
||||
return context.getString(resId, *args)
|
||||
}
|
||||
|
||||
private fun setMetadataDisplayMode(mode: DisplayMode) {
|
||||
lastDisplayMode = mode
|
||||
|
||||
if (mode == DisplayMode.Stopped) {
|
||||
albumArtImageView?.setImageDrawable(null)
|
||||
mainTrackMetadataWithAlbumArt?.visibility = View.GONE
|
||||
mainTrackMetadataNoAlbumArt?.visibility = View.GONE
|
||||
}
|
||||
else if (mode == DisplayMode.Artwork) {
|
||||
mainTrackMetadataWithAlbumArt?.visibility = View.VISIBLE
|
||||
mainTrackMetadataNoAlbumArt?.visibility = View.GONE
|
||||
}
|
||||
else {
|
||||
albumArtImageView?.setImageDrawable(null)
|
||||
mainTrackMetadataWithAlbumArt?.visibility = View.GONE
|
||||
mainTrackMetadataNoAlbumArt?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun rebindAlbumArtistWithArtTextView(playback: PlaybackService) {
|
||||
val buffering = playback.playbackState == PlaybackState.Buffering
|
||||
|
||||
val artist = playback.getTrackString(
|
||||
Metadata.Track.ARTIST, getString(if (buffering) R.string.buffering else R.string.unknown_artist))
|
||||
|
||||
val album = playback.getTrackString(
|
||||
Metadata.Track.ALBUM, getString(if (buffering) R.string.buffering else R.string.unknown_album))
|
||||
|
||||
val albumColor = ForegroundColorSpan(resources.getColor(R.color.theme_orange))
|
||||
|
||||
val artistColor = ForegroundColorSpan(resources.getColor(R.color.theme_yellow))
|
||||
|
||||
val builder = SpannableStringBuilder().append(album).append(" - ").append(artist)
|
||||
|
||||
val albumClickable = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
navigateToCurrentAlbum()
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {}
|
||||
}
|
||||
|
||||
val artistClickable = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
navigateToCurrentArtist()
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {}
|
||||
}
|
||||
|
||||
val artistOffset = album.length + 3
|
||||
|
||||
builder.setSpan(albumColor, 0, album.length, 0)
|
||||
builder.setSpan(albumClickable, 0, album.length, 0)
|
||||
builder.setSpan(artistColor, artistOffset, artistOffset + artist.length, 0)
|
||||
builder.setSpan(artistClickable, artistOffset, artistOffset + artist.length, 0)
|
||||
|
||||
this.artistAndAlbumWithArt?.movementMethod = LinkMovementMethod.getInstance()
|
||||
this.artistAndAlbumWithArt?.highlightColor = Color.TRANSPARENT
|
||||
this.artistAndAlbumWithArt?.text = builder
|
||||
}
|
||||
|
||||
private fun updateAlbumArt() {
|
||||
if (playbackService.playbackState == PlaybackState.Stopped) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork)
|
||||
}
|
||||
|
||||
val url = albumArtModel.url
|
||||
|
||||
if (Strings.empty(url)) {
|
||||
this.lastArtworkUrl = null
|
||||
albumArtModel.fetch()
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork)
|
||||
}
|
||||
else if (url != lastArtworkUrl || lastDisplayMode == DisplayMode.Stopped) {
|
||||
val loadId = albumArtModel.id
|
||||
this.lastArtworkUrl = url
|
||||
|
||||
Glide.with(context)
|
||||
.load(url)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.listener(object : RequestListener<String, GlideDrawable> {
|
||||
override fun onException(e: Exception, model: String, target: Target<GlideDrawable>, first: Boolean): Boolean {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork)
|
||||
lastArtworkUrl = null
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: GlideDrawable, model: String, target: Target<GlideDrawable>, memory: Boolean, first: Boolean): Boolean {
|
||||
if (!isPaused) {
|
||||
preloadNextImage()
|
||||
}
|
||||
|
||||
/* if the loadId doesn't match the current id, then the image was
|
||||
loaded for a different song. throw it away. */
|
||||
if (albumArtModel.id != loadId) {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
setMetadataDisplayMode(DisplayMode.Artwork)
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
.into(albumArtImageView!!)
|
||||
}
|
||||
else {
|
||||
setMetadataDisplayMode(lastDisplayMode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preloadNextImage() {
|
||||
val request = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryPlayQueueTracks)
|
||||
.addOption(Messages.Key.OFFSET, playbackService.queuePosition + 1)
|
||||
.addOption(Messages.Key.LIMIT, 1)
|
||||
.build()
|
||||
|
||||
this.wss?.send(request, wssClient) { response: SocketMessage ->
|
||||
val data = response.getJsonArrayOption(Messages.Key.DATA, JSONArray())
|
||||
if (data != null && data.length() > 0) {
|
||||
val track = data.optJSONObject(0)
|
||||
val artist = track.optString(Metadata.Track.ARTIST, "")
|
||||
val album = track.optString(Metadata.Track.ALBUM, "")
|
||||
|
||||
if (!albumArtModel.matches(artist, album)) {
|
||||
AlbumArtModel("", artist, album, AlbumArtModel.Size.Mega, object: AlbumArtCallback {
|
||||
override fun onFinished(model: AlbumArtModel, url: String?) {
|
||||
val width = albumArtImageView!!.width
|
||||
val height = albumArtImageView!!.height
|
||||
Glide.with(context).load(url).downloadOnly(width, height)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
this.prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
this.wss = WebSocketService.getInstance(context)
|
||||
|
||||
val child = LayoutInflater.from(context).inflate(R.layout.main_metadata, this, false)
|
||||
|
||||
addView(child, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
|
||||
this.title = findViewById(R.id.track_title) as TextView
|
||||
this.artist = findViewById(R.id.track_artist) as TextView
|
||||
this.album = findViewById(R.id.track_album) as TextView
|
||||
this.volume = findViewById(R.id.volume) as TextView
|
||||
this.buffering = findViewById(R.id.buffering)
|
||||
|
||||
this.titleWithArt = findViewById(R.id.with_art_track_title) as TextView
|
||||
this.artistAndAlbumWithArt = findViewById(R.id.with_art_artist_and_album) as TextView
|
||||
this.volumeWithArt = findViewById(R.id.with_art_volume) as TextView
|
||||
|
||||
this.mainTrackMetadataWithAlbumArt = findViewById(R.id.main_track_metadata_with_art)
|
||||
this.mainTrackMetadataNoAlbumArt = findViewById(R.id.main_track_metadata_without_art)
|
||||
this.albumArtImageView = findViewById(R.id.album_art) as ImageView
|
||||
|
||||
this.album?.setOnClickListener { _ -> navigateToCurrentAlbum() }
|
||||
this.artist?.setOnClickListener { _ -> navigateToCurrentArtist() }
|
||||
}
|
||||
|
||||
private fun navigateToCurrentArtist() {
|
||||
val context = context
|
||||
val playback = playbackService
|
||||
|
||||
val artistId = playback.getTrackLong(Metadata.Track.ARTIST_ID, -1)
|
||||
if (artistId != -1L) {
|
||||
val artistName = playback.getTrackString(Metadata.Track.ARTIST, "")
|
||||
context.startActivity(AlbumBrowseActivity.getStartIntent(
|
||||
context, Messages.Category.ARTIST, artistId, artistName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToCurrentAlbum() {
|
||||
val context = context
|
||||
val playback = playbackService
|
||||
|
||||
val albumId = playback.getTrackLong(Metadata.Track.ALBUM_ID, -1)
|
||||
if (albumId != -1L) {
|
||||
val albumName = playback.getTrackString(Metadata.Track.ALBUM, "")
|
||||
context.startActivity(TrackListActivity.getStartIntent(
|
||||
context, Messages.Category.ALBUM, albumId, albumName))
|
||||
}
|
||||
}
|
||||
|
||||
private val wssClient = object : WebSocketService.Client {
|
||||
override fun onStateChanged(newState: WebSocketService.State, oldState: WebSocketService.State) {}
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
|
||||
private var albumArtRetrieved = object : AlbumArtCallback {
|
||||
override fun onFinished(model: AlbumArtModel, url: String?) {
|
||||
handler.post {
|
||||
if (model === albumArtModel) {
|
||||
if (Strings.empty(model.url)) {
|
||||
setMetadataDisplayMode(DisplayMode.NoArtwork)
|
||||
}
|
||||
else {
|
||||
updateAlbumArt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
public abstract class Debouncer<T> {
|
||||
private Handler handler = new Handler(Looper.getMainLooper());
|
||||
private T t = null;
|
||||
private long delay;
|
||||
|
||||
public Debouncer(long delay) {
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
public void call() {
|
||||
handler.removeCallbacks(deferred);
|
||||
handler.postDelayed(deferred, delay);
|
||||
}
|
||||
|
||||
public void call(T t) {
|
||||
this.t = t;
|
||||
handler.removeCallbacks(deferred);
|
||||
handler.postDelayed(deferred, delay);
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
handler.removeCallbacks(deferred);
|
||||
}
|
||||
|
||||
protected abstract void onDebounced(T caller);
|
||||
|
||||
private Runnable deferred = () -> {
|
||||
onDebounced(t);
|
||||
};
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
abstract class Debouncer<in T>(private val delay: Long) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var last: T? = null
|
||||
|
||||
fun call() {
|
||||
handler.removeCallbacks(deferred)
|
||||
handler.postDelayed(deferred, delay)
|
||||
}
|
||||
|
||||
fun call(context: T) {
|
||||
last = context
|
||||
handler.removeCallbacks(deferred)
|
||||
handler.postDelayed(deferred, delay)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
handler.removeCallbacks(deferred)
|
||||
}
|
||||
|
||||
protected abstract fun onDebounced(last: T?)
|
||||
|
||||
private val deferred = object : Runnable {
|
||||
override fun run() {
|
||||
onDebounced(last)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class Duration {
|
||||
private Duration() {
|
||||
|
||||
}
|
||||
|
||||
public static String format(double seconds) {
|
||||
final int mins = ((int) seconds / 60);
|
||||
final int secs = (int) seconds - (mins * 60);
|
||||
return String.format(Locale.getDefault(), "%d:%02d", mins, secs);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
object Duration {
|
||||
fun format(seconds: Double): String {
|
||||
val mins = seconds.toInt() / 60
|
||||
val secs = seconds.toInt() - mins * 60
|
||||
return String.format(Locale.getDefault(), "%d:%02d", mins, secs)
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
public final class Navigation {
|
||||
private Navigation() {
|
||||
}
|
||||
|
||||
public interface RequestCode {
|
||||
int ALBUM_TRACKS_ACTIVITY = 10;
|
||||
int ALBUM_BROWSE_ACTIVITY = 11;
|
||||
int CATEGORY_TRACKS_ACTIVITY = 12;
|
||||
}
|
||||
|
||||
public interface ResponseCode {
|
||||
int PLAYBACK_STARTED = Activity.RESULT_FIRST_USER + 0xbeef;
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
object Navigation {
|
||||
interface RequestCode {
|
||||
companion object {
|
||||
val ALBUM_TRACKS_ACTIVITY = 10
|
||||
val ALBUM_BROWSE_ACTIVITY = 11
|
||||
val CATEGORY_TRACKS_ACTIVITY = 12
|
||||
}
|
||||
}
|
||||
|
||||
interface ResponseCode {
|
||||
companion object {
|
||||
val PLAYBACK_STARTED = Activity.RESULT_FIRST_USER + 0xbeef
|
||||
}
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
import com.neovisionaries.ws.client.WebSocketFactory;
|
||||
|
||||
import java.security.cert.CertificateException;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
public class NetworkUtil {
|
||||
private static SSLContext sslContext;
|
||||
private static SSLSocketFactory insecureSslSocketFactory;
|
||||
|
||||
private static SSLSocketFactory originalHttpsUrlConnectionSocketFactory;
|
||||
private static HostnameVerifier originalHttpsUrlConnectionHostnameVerifier;
|
||||
|
||||
public synchronized static void init() {
|
||||
if (originalHttpsUrlConnectionHostnameVerifier == null) {
|
||||
originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory();
|
||||
originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
|
||||
}
|
||||
|
||||
if (sslContext == null) {
|
||||
try {
|
||||
sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
|
||||
insecureSslSocketFactory = sslContext.getSocketFactory();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void disableCertificateValidation(final OkHttpClient.Builder okHttpClient) {
|
||||
okHttpClient.sslSocketFactory(insecureSslSocketFactory, (X509TrustManager)trustAllCerts[0]);
|
||||
okHttpClient.hostnameVerifier((hostname, session) -> true);
|
||||
}
|
||||
|
||||
public static void disableCertificateValidation(final WebSocketFactory socketFactory) {
|
||||
socketFactory.setSSLContext(sslContext);
|
||||
}
|
||||
|
||||
public static void disableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory);
|
||||
HttpsURLConnection.setDefaultHostnameVerifier((url, session) -> true);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("should never happen");
|
||||
}
|
||||
}
|
||||
|
||||
public static void enableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory);
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("should never happen");
|
||||
}
|
||||
}
|
||||
|
||||
private static final TrustManager[] trustAllCerts = new TrustManager[] {
|
||||
new X509TrustManager() {
|
||||
@Override
|
||||
public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
|
||||
return new java.security.cert.X509Certificate[]{};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private NetworkUtil() {
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
import com.neovisionaries.ws.client.WebSocketFactory
|
||||
|
||||
import java.security.cert.CertificateException
|
||||
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
object NetworkUtil {
|
||||
private var sslContext: SSLContext? = null
|
||||
private var insecureSslSocketFactory: SSLSocketFactory? = null
|
||||
private var originalHttpsUrlConnectionSocketFactory: SSLSocketFactory? = null
|
||||
private var originalHttpsUrlConnectionHostnameVerifier: HostnameVerifier? = null
|
||||
|
||||
@Synchronized fun init() {
|
||||
if (originalHttpsUrlConnectionHostnameVerifier == null) {
|
||||
originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
|
||||
originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
}
|
||||
|
||||
if (sslContext == null) {
|
||||
try {
|
||||
sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext?.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
insecureSslSocketFactory = sslContext?.socketFactory
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
throw RuntimeException(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(okHttpClient: OkHttpClient.Builder) {
|
||||
okHttpClient.sslSocketFactory(insecureSslSocketFactory!!, trustAllCerts[0] as X509TrustManager)
|
||||
okHttpClient.hostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(socketFactory: WebSocketFactory) {
|
||||
socketFactory.sslContext = sslContext
|
||||
}
|
||||
|
||||
fun disableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
|
||||
}
|
||||
catch (e: Exception) {
|
||||
throw RuntimeException("should never happen")
|
||||
}
|
||||
}
|
||||
|
||||
fun enableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
throw RuntimeException("should never happen")
|
||||
}
|
||||
}
|
||||
|
||||
private val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
|
||||
return arrayOf()
|
||||
}
|
||||
})
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
import android.os.Looper;
|
||||
|
||||
public class Preconditions {
|
||||
public static void throwIfNotOnMainThread() {
|
||||
if (Thread.currentThread() != Looper.getMainLooper().getThread()) {
|
||||
throw new IllegalStateException("not on main thread");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
import android.os.Looper
|
||||
|
||||
object Preconditions {
|
||||
fun throwIfNotOnMainThread() {
|
||||
if (Thread.currentThread() !== Looper.getMainLooper().thread) {
|
||||
throw IllegalStateException("not on main thread")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package io.casey.musikcube.remote.util;
|
||||
|
||||
public class Strings {
|
||||
public static boolean empty(final String s) {
|
||||
return (s == null || s.length() == 0);
|
||||
}
|
||||
|
||||
public static boolean notEmpty(final String s) {
|
||||
return (s != null && s.length() > 0);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package io.casey.musikcube.remote.util
|
||||
|
||||
object Strings {
|
||||
fun empty(s: String?): Boolean {
|
||||
return s == null || s.isEmpty()
|
||||
}
|
||||
|
||||
fun notEmpty(s: String?): Boolean {
|
||||
return s != null && s.isNotEmpty()
|
||||
}
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
package io.casey.musikcube.remote.websocket;
|
||||
|
||||
public class Messages {
|
||||
public enum Request {
|
||||
Authenticate("authenticate"),
|
||||
Ping("ping"),
|
||||
GetCurrentTime("get_current_time"),
|
||||
GetPlaybackOverview("get_playback_overview"),
|
||||
PauseOrResume("pause_or_resume"),
|
||||
Stop("stop"),
|
||||
Previous("previous"),
|
||||
Next("next"),
|
||||
PlayAtIndex("play_at_index"),
|
||||
ToggleShuffle("toggle_shuffle"),
|
||||
ToggleRepeat("toggle_repeat"),
|
||||
ToggleMute("toggle_mute"),
|
||||
SetVolume("set_volume"),
|
||||
SeekTo("seek_to"),
|
||||
SeekRelative("seek_relative"),
|
||||
PlayAllTracks("play_all_tracks"),
|
||||
PlayTracks("play_tracks"),
|
||||
PlayTracksByCategory("play_tracks_by_category"),
|
||||
QueryTracks("query_tracks"),
|
||||
QueryTracksByCategory("query_tracks_by_category"),
|
||||
QueryCategory("query_category"),
|
||||
QueryAlbums("query_albums"),
|
||||
QueryPlayQueueTracks("query_play_queue_tracks");
|
||||
|
||||
private String rawValue;
|
||||
|
||||
Request(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public static Request from(String rawValue) {
|
||||
for (final Request value : Request.values()) {
|
||||
if (value.toString().equals(rawValue)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean is(final String name) {
|
||||
return rawValue.equals(name);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Broadcast {
|
||||
PlaybackOverviewChanged("playback_overview_changed"),
|
||||
PlayQueueChanged("play_queue_changed");
|
||||
|
||||
private String rawValue;
|
||||
|
||||
Broadcast(String rawValue) {
|
||||
this.rawValue = rawValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
public boolean is(String rawValue) {
|
||||
return this.rawValue.equals(rawValue);
|
||||
}
|
||||
|
||||
public static Broadcast from(String rawValue) {
|
||||
for (final Broadcast value : Broadcast.values()) {
|
||||
if (value.toString().equals(rawValue)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Key {
|
||||
String CATEGORY = "category";
|
||||
String CATEGORY_ID = "category_id";
|
||||
String DATA = "data";
|
||||
String ID = "id";
|
||||
String IDS = "ids";
|
||||
String COUNT = "count";
|
||||
String COUNT_ONLY = "count_only";
|
||||
String OFFSET = "offset";
|
||||
String LIMIT = "limit";
|
||||
String INDEX = "index";
|
||||
String DELTA = "delta";
|
||||
String POSITION = "position";
|
||||
String VALUE = "value";
|
||||
String FILTER = "filter";
|
||||
String RELATIVE = "relative";
|
||||
String PLAYING_CURRENT_TIME = "playing_current_time";
|
||||
}
|
||||
|
||||
public interface Value {
|
||||
String DELTA = "delta";
|
||||
String UP = "up";
|
||||
String DOWN = "down";
|
||||
}
|
||||
|
||||
public interface Category {
|
||||
String OFFLINE = "offline";
|
||||
String ALBUM = "album";
|
||||
String ARTIST = "artist";
|
||||
String ALBUM_ARTIST = "album_artist";
|
||||
String GENRE = "genre";
|
||||
String PLAYLISTS = "playlists";
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
package io.casey.musikcube.remote.websocket
|
||||
|
||||
class Messages {
|
||||
enum class Request constructor(private val rawValue: String) {
|
||||
Authenticate("authenticate"),
|
||||
Ping("ping"),
|
||||
GetCurrentTime("get_current_time"),
|
||||
GetPlaybackOverview("get_playback_overview"),
|
||||
PauseOrResume("pause_or_resume"),
|
||||
Stop("stop"),
|
||||
Previous("previous"),
|
||||
Next("next"),
|
||||
PlayAtIndex("play_at_index"),
|
||||
ToggleShuffle("toggle_shuffle"),
|
||||
ToggleRepeat("toggle_repeat"),
|
||||
ToggleMute("toggle_mute"),
|
||||
SetVolume("set_volume"),
|
||||
SeekTo("seek_to"),
|
||||
SeekRelative("seek_relative"),
|
||||
PlayAllTracks("play_all_tracks"),
|
||||
PlayTracks("play_tracks"),
|
||||
PlayTracksByCategory("play_tracks_by_category"),
|
||||
QueryTracks("query_tracks"),
|
||||
QueryTracksByCategory("query_tracks_by_category"),
|
||||
QueryCategory("query_category"),
|
||||
QueryAlbums("query_albums"),
|
||||
QueryPlayQueueTracks("query_play_queue_tracks");
|
||||
|
||||
override fun toString(): String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
fun matches(name: String): Boolean {
|
||||
return rawValue == name
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(rawValue: String): Request? {
|
||||
for (value in Request.values()) {
|
||||
if (value.toString() == rawValue) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Broadcast constructor(private val rawValue: String) {
|
||||
PlaybackOverviewChanged("playback_overview_changed"),
|
||||
PlayQueueChanged("play_queue_changed");
|
||||
|
||||
override fun toString(): String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
fun matches(rawValue: String): Boolean {
|
||||
return this.rawValue == rawValue
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(rawValue: String): Broadcast? {
|
||||
for (value in Broadcast.values()) {
|
||||
if (value.toString() == rawValue) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Key {
|
||||
companion object {
|
||||
val CATEGORY = "category"
|
||||
val CATEGORY_ID = "category_id"
|
||||
val DATA = "data"
|
||||
val ID = "id"
|
||||
val IDS = "ids"
|
||||
val COUNT = "count"
|
||||
val COUNT_ONLY = "count_only"
|
||||
val OFFSET = "offset"
|
||||
val LIMIT = "limit"
|
||||
val INDEX = "index"
|
||||
val DELTA = "delta"
|
||||
val POSITION = "position"
|
||||
val VALUE = "value"
|
||||
val FILTER = "filter"
|
||||
val RELATIVE = "relative"
|
||||
val PLAYING_CURRENT_TIME = "playing_current_time"
|
||||
}
|
||||
}
|
||||
|
||||
interface Value {
|
||||
companion object {
|
||||
val DELTA = "delta"
|
||||
val UP = "up"
|
||||
val DOWN = "down"
|
||||
}
|
||||
}
|
||||
|
||||
interface Category {
|
||||
companion object {
|
||||
val OFFLINE = "offline"
|
||||
val ALBUM = "album"
|
||||
val ARTIST = "artist"
|
||||
val ALBUM_ARTIST = "album_artist"
|
||||
val GENRE = "genre"
|
||||
val PLAYLISTS = "playlists"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
package io.casey.musikcube.remote.websocket;
|
||||
|
||||
public interface Prefs {
|
||||
String NAME = "prefs";
|
||||
|
||||
interface Key {
|
||||
String ADDRESS = "address";
|
||||
String MAIN_PORT = "port";
|
||||
String AUDIO_PORT = "http_port";
|
||||
String PASSWORD = "password";
|
||||
String ALBUM_ART_ENABLED = "album_art_enabled";
|
||||
String MESSAGE_COMPRESSION_ENABLED = "message_compression_enabled";
|
||||
String STREAMING_PLAYBACK = "streaming_playback";
|
||||
String SOFTWARE_VOLUME = "software_volume";
|
||||
String SSL_ENABLED = "ssl_enabled";
|
||||
String CERT_VALIDATION_DISABLED = "cert_validation_disabled";
|
||||
String TRANSCODER_BITRATE_INDEX = "transcoder_bitrate_index";
|
||||
String DISK_CACHE_SIZE_INDEX = "disk_cache_size_index";
|
||||
String SYSTEM_SERVICE_FOR_REMOTE = "system_service_for_remote";
|
||||
}
|
||||
|
||||
interface Default {
|
||||
String ADDRESS = "192.168.1.100";
|
||||
int MAIN_PORT = 7905;
|
||||
int AUDIO_PORT = 7906;
|
||||
String PASSWORD = "";
|
||||
boolean ALBUM_ART_ENABLED = true;
|
||||
boolean MESSAGE_COMPRESSION_ENABLED = true;
|
||||
boolean STREAMING_PLAYBACK = false;
|
||||
boolean SOFTWARE_VOLUME = false;
|
||||
boolean SSL_ENABLED = false;
|
||||
boolean CERT_VALIDATION_DISABLED = false;
|
||||
int TRANSCODER_BITRATE_INDEX = 0;
|
||||
int DISK_CACHE_SIZE_INDEX = 0;
|
||||
boolean SYSTEM_SERVICE_FOR_REMOTE = false;
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package io.casey.musikcube.remote.websocket
|
||||
|
||||
class Prefs {
|
||||
interface Key {
|
||||
companion object {
|
||||
val ADDRESS = "address"
|
||||
val MAIN_PORT = "port"
|
||||
val AUDIO_PORT = "http_port"
|
||||
val PASSWORD = "password"
|
||||
val ALBUM_ART_ENABLED = "album_art_enabled"
|
||||
val MESSAGE_COMPRESSION_ENABLED = "message_compression_enabled"
|
||||
val STREAMING_PLAYBACK = "streaming_playback"
|
||||
val SOFTWARE_VOLUME = "software_volume"
|
||||
val SSL_ENABLED = "ssl_enabled"
|
||||
val CERT_VALIDATION_DISABLED = "cert_validation_disabled"
|
||||
val TRANSCODER_BITRATE_INDEX = "transcoder_bitrate_index"
|
||||
val DISK_CACHE_SIZE_INDEX = "disk_cache_size_index"
|
||||
val SYSTEM_SERVICE_FOR_REMOTE = "system_service_for_remote"
|
||||
}
|
||||
}
|
||||
|
||||
interface Default {
|
||||
companion object {
|
||||
val ADDRESS = "192.168.1.100"
|
||||
val MAIN_PORT = 7905
|
||||
val AUDIO_PORT = 7906
|
||||
val PASSWORD = ""
|
||||
val ALBUM_ART_ENABLED = true
|
||||
val MESSAGE_COMPRESSION_ENABLED = true
|
||||
val STREAMING_PLAYBACK = false
|
||||
val SOFTWARE_VOLUME = false
|
||||
val SSL_ENABLED = false
|
||||
val CERT_VALIDATION_DISABLED = false
|
||||
val TRANSCODER_BITRATE_INDEX = 0
|
||||
val DISK_CACHE_SIZE_INDEX = 0
|
||||
val SYSTEM_SERVICE_FOR_REMOTE = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NAME = "prefs"
|
||||
}
|
||||
}
|
@ -1,318 +0,0 @@
|
||||
package io.casey.musikcube.remote.websocket;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class SocketMessage {
|
||||
private static final String TAG = SocketMessage.class.getCanonicalName();
|
||||
|
||||
public enum Type {
|
||||
Request("request"),
|
||||
Response("response"),
|
||||
Broadcast("broadcast");
|
||||
|
||||
private String rawType;
|
||||
|
||||
Type(String rawType) {
|
||||
this.rawType = rawType;
|
||||
}
|
||||
|
||||
public String getRawType() {
|
||||
return rawType;
|
||||
}
|
||||
|
||||
public static Type fromString(final String str) {
|
||||
if (Request.rawType.equals(str)) {
|
||||
return Request;
|
||||
}
|
||||
else if (Response.rawType.equals(str)) {
|
||||
return Response;
|
||||
}
|
||||
else if (Broadcast.rawType.equals(str)) {
|
||||
return Broadcast;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("str");
|
||||
}
|
||||
}
|
||||
|
||||
private String name;
|
||||
private String id;
|
||||
private Type type;
|
||||
private JSONObject options;
|
||||
|
||||
public static SocketMessage create(String string) {
|
||||
try {
|
||||
JSONObject object = new JSONObject(string);
|
||||
final String name = object.getString("name");
|
||||
final Type type = Type.fromString(object.getString("type"));
|
||||
final String id = object.getString("id");
|
||||
final JSONObject options = object.optJSONObject("options");
|
||||
return new SocketMessage(name, id, type, options);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Log.e(TAG, ex.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SocketMessage(String name, String id, Type type, JSONObject options) {
|
||||
if (name == null || name.length() == 0 || id == null || id.length() == 0 || type == null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.options = (options == null) ? new JSONObject() : options;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public <T> T getOption(final String key) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return (T) options.get(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getStringOption(final String key) {
|
||||
return getStringOption(key, "");
|
||||
}
|
||||
|
||||
public String getStringOption(final String key, final String defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getString(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public int getIntOption(final String key) {
|
||||
return getIntOption(key, 0);
|
||||
}
|
||||
|
||||
public int getIntOption(final String key, final int defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getInt(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public long getLongOption(final String key) {
|
||||
return getLongOption(key, 0);
|
||||
}
|
||||
|
||||
public long getLongOption(final String key, final long defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getLong(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public double getDoubleOption(final String key) {
|
||||
return getDoubleOption(key, 0.0);
|
||||
}
|
||||
|
||||
public double getDoubleOption(final String key, final double defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getDouble(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public boolean getBooleanOption(final String key) {
|
||||
return getBooleanOption(key, false);
|
||||
}
|
||||
|
||||
public boolean getBooleanOption(final String key, final boolean defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getBoolean(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public JSONObject getJsonObjectOption(final String key) {
|
||||
return getJsonObjectOption(key, null);
|
||||
}
|
||||
|
||||
public JSONObject getJsonObjectOption(final String key, final JSONObject defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONObject(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public JSONArray getJsonArrayOption(final String key) {
|
||||
return getJsonArrayOption(key, null);
|
||||
}
|
||||
|
||||
public JSONArray getJsonArrayOption(final String key, final JSONArray defaultValue) {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONArray(key);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
try {
|
||||
final JSONObject json = new JSONObject();
|
||||
json.put("name", name);
|
||||
json.put("id", id);
|
||||
json.put("type", type.getRawType());
|
||||
json.put("options", options);
|
||||
return json.toString();
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException("unable to generate output JSON!");
|
||||
}
|
||||
}
|
||||
|
||||
public Builder buildUpon() {
|
||||
try {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name;
|
||||
builder.type = type;
|
||||
builder.id = id;
|
||||
builder.options = new JSONObject(options.toString());
|
||||
return builder;
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private static AtomicInteger nextId = new AtomicInteger();
|
||||
|
||||
private String name;
|
||||
private Type type;
|
||||
private String id;
|
||||
private JSONObject options = new JSONObject();
|
||||
|
||||
private Builder() {
|
||||
|
||||
}
|
||||
|
||||
private static String newId() {
|
||||
return String.format(Locale.ENGLISH, "musikcube-android-client-%d", nextId.incrementAndGet());
|
||||
}
|
||||
|
||||
public static Builder broadcast(String name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name;
|
||||
builder.id = newId();
|
||||
builder.type = Type.Response;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder respondTo(final SocketMessage message) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = message.getName();
|
||||
builder.id = message.getId();
|
||||
builder.type = Type.Response;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder request(final String name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name;
|
||||
builder.id = newId();
|
||||
builder.type = Type.Request;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static Builder request(final Messages.Request name) {
|
||||
final Builder builder = new Builder();
|
||||
builder.name = name.toString();
|
||||
builder.id = newId();
|
||||
builder.type = Type.Request;
|
||||
return builder;
|
||||
}
|
||||
|
||||
public Builder withOptions(final JSONObject options) {
|
||||
if (options == null) {
|
||||
this.options = new JSONObject();
|
||||
}
|
||||
else {
|
||||
this.options = options;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addOption(final String key, final Object value) {
|
||||
try {
|
||||
options.put(key, value);
|
||||
}
|
||||
catch (JSONException ex) {
|
||||
throw new RuntimeException("addOption failed??");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder removeOption(final String key) {
|
||||
options.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SocketMessage build() {
|
||||
return new SocketMessage(name, id, type, options);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,256 @@
|
||||
package io.casey.musikcube.remote.websocket
|
||||
|
||||
import android.util.Log
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class SocketMessage private constructor(val name: String, val id: String, val type: SocketMessage.Type, options: JSONObject?) {
|
||||
enum class Type constructor(val rawType: String) {
|
||||
Request("request"),
|
||||
Response("response"),
|
||||
Broadcast("broadcast");
|
||||
|
||||
companion object {
|
||||
fun fromString(str: String): Type {
|
||||
if (Request.rawType == str) {
|
||||
return Request
|
||||
}
|
||||
else if (Response.rawType == str) {
|
||||
return Response
|
||||
}
|
||||
else if (Broadcast.rawType == str) {
|
||||
return Broadcast
|
||||
}
|
||||
throw IllegalArgumentException("str")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val options: JSONObject
|
||||
|
||||
init {
|
||||
if (name.isEmpty() || id.isEmpty()) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
this.options = options ?: JSONObject()
|
||||
}
|
||||
|
||||
fun <T> getOption(key: String): T? {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.get(key) as T
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@JvmOverloads fun getStringOption(key: String, defaultValue: String = ""): String {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getString(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getIntOption(key: String, defaultValue: Int = 0): Int {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getInt(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getLongOption(key: String, defaultValue: Long = 0): Long {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getLong(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getDoubleOption(key: String, defaultValue: Double = 0.0): Double {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getDouble(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getBooleanOption(key: String, defaultValue: Boolean = false): Boolean {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getBoolean(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getJsonObjectOption(key: String, defaultValue: JSONObject? = null): JSONObject? {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONObject(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@JvmOverloads fun getJsonArrayOption(key: String, defaultValue: JSONArray? = null): JSONArray? {
|
||||
if (options.has(key)) {
|
||||
try {
|
||||
return options.getJSONArray(key)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
try {
|
||||
val json = JSONObject()
|
||||
json.put("name", name)
|
||||
json.put("id", id)
|
||||
json.put("type", type.rawType)
|
||||
json.put("options", options)
|
||||
return json.toString()
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
throw RuntimeException("unable to generate output JSON!")
|
||||
}
|
||||
}
|
||||
|
||||
fun buildUpon(): Builder {
|
||||
try {
|
||||
val builder = Builder()
|
||||
builder._name = name
|
||||
builder._type = type
|
||||
builder._id = id
|
||||
builder._options = JSONObject(options.toString())
|
||||
return builder
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
throw RuntimeException(ex)
|
||||
}
|
||||
}
|
||||
|
||||
class Builder internal constructor() {
|
||||
internal var _name: String? = null
|
||||
internal var _type: Type? = null
|
||||
internal var _id: String? = null
|
||||
internal var _options = JSONObject()
|
||||
|
||||
fun withOptions(options: JSONObject?): Builder {
|
||||
_options = options ?: JSONObject()
|
||||
return this
|
||||
}
|
||||
|
||||
fun addOption(key: String, value: Any?): Builder {
|
||||
try {
|
||||
_options.put(key, value)
|
||||
}
|
||||
catch (ex: JSONException) {
|
||||
throw RuntimeException("addOption failed??")
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeOption(key: String): Builder {
|
||||
_options.remove(key)
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): SocketMessage {
|
||||
return SocketMessage(_name!!, _id!!, _type!!, _options)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val nextId = AtomicInteger()
|
||||
|
||||
private fun newId(): String {
|
||||
return String.format(Locale.ENGLISH, "musikcube-android-client-%d", nextId.incrementAndGet())
|
||||
}
|
||||
|
||||
fun broadcast(name: String): Builder {
|
||||
val builder = Builder()
|
||||
builder._name = name
|
||||
builder._id = newId()
|
||||
builder._type = Type.Response
|
||||
return builder
|
||||
}
|
||||
|
||||
fun respondTo(message: SocketMessage): Builder {
|
||||
val builder = Builder()
|
||||
builder._name = message.name
|
||||
builder._id = message.id
|
||||
builder._type = Type.Response
|
||||
return builder
|
||||
}
|
||||
|
||||
fun request(name: String): Builder {
|
||||
val builder = Builder()
|
||||
builder._name = name
|
||||
builder._id = newId()
|
||||
builder._type = Type.Request
|
||||
return builder
|
||||
}
|
||||
|
||||
fun request(name: Messages.Request): Builder {
|
||||
val builder = Builder()
|
||||
builder._name = name.toString()
|
||||
builder._id = newId()
|
||||
builder._type = Type.Request
|
||||
return builder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = SocketMessage::class.java.canonicalName
|
||||
|
||||
fun create(string: String): SocketMessage? {
|
||||
try {
|
||||
val `object` = JSONObject(string)
|
||||
val name = `object`.getString("name")
|
||||
val type = Type.fromString(`object`.getString("type"))
|
||||
val id = `object`.getString("id")
|
||||
val options = `object`.optJSONObject("options")
|
||||
return SocketMessage(name, id, type, options)
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
Log.e(TAG, ex.toString())
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,660 +0,0 @@
|
||||
package io.casey.musikcube.remote.websocket;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.util.Log;
|
||||
|
||||
import com.neovisionaries.ws.client.WebSocket;
|
||||
import com.neovisionaries.ws.client.WebSocketAdapter;
|
||||
import com.neovisionaries.ws.client.WebSocketExtension;
|
||||
import com.neovisionaries.ws.client.WebSocketFactory;
|
||||
import com.neovisionaries.ws.client.WebSocketFrame;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import io.casey.musikcube.remote.util.NetworkUtil;
|
||||
import io.casey.musikcube.remote.util.Preconditions;
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.ObservableEmitter;
|
||||
import io.reactivex.ObservableOnSubscribe;
|
||||
import io.reactivex.annotations.NonNull;
|
||||
|
||||
import static android.content.Context.CONNECTIVITY_SERVICE;
|
||||
|
||||
public class WebSocketService {
|
||||
private static final String TAG = "WebSocketService";
|
||||
|
||||
private static final int AUTO_RECONNECT_INTERVAL_MILLIS = 2000;
|
||||
private static final int CALLBACK_TIMEOUT_MILLIS = 30000;
|
||||
private static final int CONNECTION_TIMEOUT_MILLIS = 5000;
|
||||
private static final int PING_INTERVAL_MILLIS = 3500;
|
||||
private static final int AUTO_CONNECT_FAILSAFE_DELAY_MILLIS = 2000;
|
||||
private static final int AUTO_DISCONNECT_DELAY_MILLIS = 10000;
|
||||
private static final int FLAG_AUTHENTICATION_FAILED = 0xbeef;
|
||||
private static final int WEBSOCKET_FLAG_POLICY_VIOLATION = 1008;
|
||||
|
||||
private static final int MESSAGE_BASE = 0xcafedead;
|
||||
private static final int MESSAGE_CONNECT_THREAD_FINISHED = MESSAGE_BASE + 0;
|
||||
private static final int MESSAGE_RECEIVED = MESSAGE_BASE + 1;
|
||||
private static final int MESSAGE_REMOVE_OLD_CALLBACKS = MESSAGE_BASE + 2;
|
||||
private static final int MESSAGE_AUTO_RECONNECT = MESSAGE_BASE + 3;
|
||||
private static final int MESSAGE_SCHEDULE_PING = MESSAGE_BASE + 4;
|
||||
private static final int MESSAGE_PING_EXPIRED = MESSAGE_BASE + 5;
|
||||
|
||||
public interface Client {
|
||||
void onStateChanged(State newState, State oldState);
|
||||
void onMessageReceived(SocketMessage message);
|
||||
void onInvalidPassword();
|
||||
}
|
||||
|
||||
public interface MessageResultCallback {
|
||||
void onMessageResult(final SocketMessage response);
|
||||
}
|
||||
|
||||
private interface MessageErrorCallback {
|
||||
void onMessageError();
|
||||
}
|
||||
|
||||
private interface Predicate1<T> {
|
||||
boolean check(T value);
|
||||
}
|
||||
|
||||
public interface Interceptor {
|
||||
boolean process(SocketMessage message, Responder responder);
|
||||
}
|
||||
|
||||
public interface Responder {
|
||||
void respond(SocketMessage response);
|
||||
}
|
||||
|
||||
public enum State {
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
private Handler handler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
|
||||
@Override
|
||||
public boolean handleMessage(Message message) {
|
||||
if (message.what == MESSAGE_CONNECT_THREAD_FINISHED) {
|
||||
if (message.obj == null) {
|
||||
boolean invalidPassword = (message.arg1 == FLAG_AUTHENTICATION_FAILED);
|
||||
disconnect(!invalidPassword); /* auto reconnect as long as password was not invalid */
|
||||
|
||||
if (invalidPassword) {
|
||||
for (Client client : clients) {
|
||||
client.onInvalidPassword();
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSocket((WebSocket) message.obj);
|
||||
setState(State.Connected);
|
||||
ping();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else if (message.what == MESSAGE_RECEIVED) {
|
||||
if (clients != null) {
|
||||
final SocketMessage msg = (SocketMessage) message.obj;
|
||||
|
||||
boolean dispatched = false;
|
||||
|
||||
/* registered callback for THIS message */
|
||||
final MessageResultDescriptor mdr = messageCallbacks.remove(msg.getId());
|
||||
if (mdr != null && mdr.callback != null) {
|
||||
mdr.callback.onMessageResult(msg);
|
||||
dispatched = true;
|
||||
}
|
||||
|
||||
if (!dispatched) {
|
||||
/* service-level callback */
|
||||
for (Client client : clients) {
|
||||
client.onMessageReceived(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (mdr != null) {
|
||||
mdr.error = null;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else if (message.what == MESSAGE_REMOVE_OLD_CALLBACKS) {
|
||||
removeExpiredCallbacks();
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
else if (message.what == MESSAGE_AUTO_RECONNECT) {
|
||||
if (getState() == State.Disconnected && autoReconnect) {
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
else if (message.what == MESSAGE_SCHEDULE_PING) {
|
||||
ping();
|
||||
}
|
||||
else if (message.what == MESSAGE_PING_EXPIRED) {
|
||||
// Toast.makeText(context, "recovering...", Toast.LENGTH_LONG).show();
|
||||
removeInternalCallbacks();
|
||||
boolean reconnect = (getState() == State.Connected) || autoReconnect;
|
||||
disconnect(reconnect);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
private static class MessageResultDescriptor {
|
||||
long id;
|
||||
long enqueueTime;
|
||||
boolean intercepted;
|
||||
Client client;
|
||||
MessageResultCallback callback;
|
||||
MessageErrorCallback error;
|
||||
}
|
||||
|
||||
private static WebSocketService INSTANCE;
|
||||
private static AtomicLong NEXT_ID = new AtomicLong(0);
|
||||
|
||||
private Context context;
|
||||
private SharedPreferences prefs;
|
||||
private WebSocket socket = null;
|
||||
private State state = State.Disconnected;
|
||||
private Set<Client> clients = new HashSet<>();
|
||||
private Map<String, MessageResultDescriptor> messageCallbacks = new HashMap<>();
|
||||
private boolean autoReconnect = false;
|
||||
private NetworkChangedReceiver networkChanged = new NetworkChangedReceiver();
|
||||
private ConnectThread thread;
|
||||
private Set<Interceptor> interceptors = new HashSet<>();
|
||||
|
||||
public static synchronized WebSocketService getInstance(final Context context) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new WebSocketService(context);
|
||||
}
|
||||
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private WebSocketService(final Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.prefs = this.context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
public void addInterceptor(final Interceptor interceptor) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
interceptors.add(interceptor);
|
||||
}
|
||||
|
||||
public void removeInterceptor(final Interceptor interceptor) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
interceptors.remove(interceptor);
|
||||
}
|
||||
|
||||
public void addClient(Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (!this.clients.contains(client)) {
|
||||
this.clients.add(client);
|
||||
|
||||
if (this.clients.size() >= 0 && state == State.Disconnected) {
|
||||
registerReceiverAndScheduleFailsafe();
|
||||
reconnect();
|
||||
}
|
||||
|
||||
handler.removeCallbacks(autoDisconnectRunnable);
|
||||
client.onStateChanged(getState(), getState());
|
||||
}
|
||||
}
|
||||
|
||||
public void removeClient(Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
if (this.clients.remove(client)) {
|
||||
removeCallbacksForClient(client);
|
||||
|
||||
if (this.clients.size() == 0) {
|
||||
unregisterReceiverAndCancelFailsafe();
|
||||
handler.postDelayed(autoDisconnectRunnable, AUTO_DISCONNECT_DELAY_MILLIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasClient(Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
return this.clients.contains(client);
|
||||
}
|
||||
|
||||
public void reconnect() {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
autoReconnect = true;
|
||||
connectIfNotConnected();
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
disconnect(false); /* don't auto-reconnect */
|
||||
}
|
||||
|
||||
public State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void cancelMessage(final long id) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.id == id);
|
||||
}
|
||||
|
||||
private void ping() {
|
||||
if (state == State.Connected) {
|
||||
//Log.i("WebSocketService", "ping");
|
||||
removeInternalCallbacks();
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_PING_EXPIRED, PING_INTERVAL_MILLIS);
|
||||
|
||||
final SocketMessage ping = SocketMessage.Builder
|
||||
.request(Messages.Request.Ping).build();
|
||||
|
||||
send(ping, INTERNAL_CLIENT, (SocketMessage response) -> {
|
||||
//Log.i("WebSocketService", "pong");
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED);
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_SCHEDULE_PING, PING_INTERVAL_MILLIS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void cancelMessages(final Client client) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.client == client);
|
||||
}
|
||||
|
||||
public long send(final SocketMessage message) {
|
||||
return send(message, null, null);
|
||||
}
|
||||
|
||||
public long send(final SocketMessage message, Client client, MessageResultCallback callback) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
boolean intercepted = false;
|
||||
|
||||
for (final Interceptor i : interceptors) {
|
||||
if (i.process(message, responder)) {
|
||||
intercepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
|
||||
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
|
||||
a reconnect until we can find a better root cause. this is very difficult to repro */
|
||||
if (this.socket != null && !this.socket.isOpen()) {
|
||||
this.disconnect(true);
|
||||
return -1;
|
||||
}
|
||||
else if (this.socket == null) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
final long id = NEXT_ID.incrementAndGet();
|
||||
|
||||
if (callback != null) {
|
||||
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
|
||||
throw new IllegalArgumentException("client is not registered");
|
||||
}
|
||||
|
||||
final MessageResultDescriptor mrd = new MessageResultDescriptor();
|
||||
mrd.id = id;
|
||||
mrd.enqueueTime = System.currentTimeMillis();
|
||||
mrd.client = client;
|
||||
mrd.callback = callback;
|
||||
mrd.intercepted = intercepted;
|
||||
messageCallbacks.put(message.getId(), mrd);
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
this.socket.sendText(message.toString());
|
||||
}
|
||||
else {
|
||||
Log.d(TAG, "send: message intercepted with id " + String.valueOf(id));
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public Observable<SocketMessage> send(final SocketMessage message, Client client) {
|
||||
return Observable.create(new ObservableOnSubscribe<SocketMessage>() {
|
||||
@Override
|
||||
public void subscribe(@NonNull ObservableEmitter<SocketMessage> emitter) throws Exception {
|
||||
try {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
boolean intercepted = false;
|
||||
|
||||
for (final Interceptor i : interceptors) {
|
||||
if (i.process(message, responder)) {
|
||||
intercepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
|
||||
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
|
||||
a reconnect until we can find a better root cause. this is very difficult to repro */
|
||||
if (socket != null && !socket.isOpen()) {
|
||||
disconnect(true);
|
||||
throw new Exception("socket disconnected");
|
||||
}
|
||||
else if (socket == null) {
|
||||
throw new Exception("socket not connected");
|
||||
}
|
||||
}
|
||||
|
||||
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
|
||||
throw new IllegalArgumentException("client is not registered");
|
||||
}
|
||||
|
||||
final MessageResultDescriptor mrd = new MessageResultDescriptor();
|
||||
mrd.id = NEXT_ID.incrementAndGet();
|
||||
mrd.enqueueTime = System.currentTimeMillis();
|
||||
mrd.client = client;
|
||||
mrd.intercepted = intercepted;
|
||||
|
||||
mrd.callback = (SocketMessage message) -> {
|
||||
emitter.onNext(message);
|
||||
emitter.onComplete();
|
||||
};
|
||||
|
||||
mrd.error = () -> {
|
||||
final Exception ex = new Exception();
|
||||
ex.fillInStackTrace();
|
||||
emitter.onError(ex);
|
||||
};
|
||||
|
||||
messageCallbacks.put(message.getId(), mrd);
|
||||
|
||||
if (!intercepted) {
|
||||
socket.sendText(message.toString());
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
emitter.onError(ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean hasValidConnection() {
|
||||
final String addr = prefs.getString(Prefs.Key.ADDRESS, "");
|
||||
final int port = prefs.getInt(Prefs.Key.MAIN_PORT, -1);
|
||||
return (addr.length() > 0 && port >= 0);
|
||||
}
|
||||
|
||||
private void disconnect(boolean autoReconnect) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
synchronized (this) {
|
||||
if (this.thread != null) {
|
||||
this.thread.interrupt();
|
||||
this.thread = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.autoReconnect = autoReconnect;
|
||||
|
||||
if (this.socket != null) {
|
||||
this.socket.removeListener(webSocketAdapter);
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
removeNonInterceptedCallbacks();
|
||||
setState(State.Disconnected);
|
||||
|
||||
if (autoReconnect) {
|
||||
this.handler.sendEmptyMessageDelayed(
|
||||
MESSAGE_AUTO_RECONNECT,
|
||||
AUTO_RECONNECT_INTERVAL_MILLIS);
|
||||
}
|
||||
else {
|
||||
this.handler.removeMessages(MESSAGE_AUTO_RECONNECT);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeNonInterceptedCallbacks() {
|
||||
removeCallbacks((mrd) -> !mrd.intercepted);
|
||||
}
|
||||
|
||||
private void removeInternalCallbacks() {
|
||||
removeCallbacks((MessageResultDescriptor mrd) -> mrd.client == INTERNAL_CLIENT);
|
||||
}
|
||||
|
||||
private void removeExpiredCallbacks() {
|
||||
final long now = System.currentTimeMillis();
|
||||
|
||||
removeCallbacks((MessageResultDescriptor value) ->
|
||||
now - value.enqueueTime > CALLBACK_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
private void removeCallbacksForClient(final Client client) {
|
||||
removeCallbacks((MessageResultDescriptor value) -> value == client);
|
||||
}
|
||||
|
||||
private void removeCallbacks(Predicate1<MessageResultDescriptor> predicate) {
|
||||
final Iterator<Map.Entry<String, MessageResultDescriptor>> it
|
||||
= messageCallbacks.entrySet().iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
final Map.Entry<String, MessageResultDescriptor> entry = it.next();
|
||||
final MessageResultDescriptor mdr = entry.getValue();
|
||||
if (predicate.check(mdr)) {
|
||||
if (mdr.error != null) {
|
||||
mdr.error.onMessageError();
|
||||
}
|
||||
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void connectIfNotConnected() {
|
||||
if (state == State.Disconnected) {
|
||||
disconnect(autoReconnect);
|
||||
handler.removeMessages(MESSAGE_AUTO_RECONNECT);
|
||||
|
||||
if (this.clients.size() > 0) {
|
||||
handler.removeCallbacks(autoDisconnectRunnable);
|
||||
setState(State.Connecting);
|
||||
thread = new ConnectThread();
|
||||
thread.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setSocket(WebSocket socket) {
|
||||
if (this.socket != socket) {
|
||||
if (this.socket != null) {
|
||||
this.socket.removeListener(webSocketAdapter);
|
||||
}
|
||||
|
||||
this.socket = socket;
|
||||
}
|
||||
}
|
||||
|
||||
private void setState(State state) {
|
||||
Preconditions.throwIfNotOnMainThread();
|
||||
|
||||
Log.d(TAG, "state = " + state);
|
||||
|
||||
if (this.state != state) {
|
||||
State old = this.state;
|
||||
this.state = state;
|
||||
|
||||
for (Client client : this.clients) {
|
||||
client.onStateChanged(state, old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void registerReceiverAndScheduleFailsafe() {
|
||||
unregisterReceiverAndCancelFailsafe();
|
||||
|
||||
/* generally raises a CONNECTIVITY_ACTION event immediately,
|
||||
even if already connected. */
|
||||
final IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
|
||||
context.registerReceiver(networkChanged, filter);
|
||||
|
||||
/* however, CONNECTIVITY_ACTION doesn't ALWAYS seem to be raised,
|
||||
so we schedule a failsafe just in case */
|
||||
this.handler.postDelayed(autoReconnectFailsafeRunnable, AUTO_CONNECT_FAILSAFE_DELAY_MILLIS);
|
||||
}
|
||||
|
||||
private void unregisterReceiverAndCancelFailsafe() {
|
||||
handler.removeCallbacks(autoReconnectFailsafeRunnable);
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(networkChanged);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
|
||||
private Runnable autoReconnectFailsafeRunnable = () -> {
|
||||
if (autoReconnect && getState() == State.Disconnected) {
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
|
||||
private Runnable autoDisconnectRunnable = () -> disconnect();
|
||||
|
||||
private Responder responder = (response) -> {
|
||||
/* post to the back of the queue in case the interceptor responded immediately;
|
||||
we need to ensure all of the request book-keeping has been finished. */
|
||||
handler.post(() -> {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_RECEIVED, response));
|
||||
});
|
||||
};
|
||||
|
||||
private WebSocketAdapter webSocketAdapter = new WebSocketAdapter() {
|
||||
@Override
|
||||
public void onTextMessage(WebSocket websocket, String text) throws Exception {
|
||||
final SocketMessage message = SocketMessage.create(text);
|
||||
if (message != null) {
|
||||
if (message.getName().equals(Messages.Request.Authenticate.toString())) {
|
||||
handler.sendMessage(Message.obtain(
|
||||
handler, MESSAGE_CONNECT_THREAD_FINISHED, websocket));
|
||||
}
|
||||
else {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_RECEIVED, message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(WebSocket websocket,
|
||||
WebSocketFrame serverCloseFrame,
|
||||
WebSocketFrame clientCloseFrame,
|
||||
boolean closedByServer) throws Exception {
|
||||
int flags = 0;
|
||||
if (serverCloseFrame.getCloseCode() == WEBSOCKET_FLAG_POLICY_VIOLATION) {
|
||||
flags = FLAG_AUTHENTICATION_FAILED;
|
||||
}
|
||||
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_CONNECT_THREAD_FINISHED, flags, 0, null));
|
||||
}
|
||||
};
|
||||
|
||||
private class ConnectThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
WebSocket socket;
|
||||
|
||||
try {
|
||||
final WebSocketFactory factory = new WebSocketFactory();
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, Prefs.Default.CERT_VALIDATION_DISABLED)) {
|
||||
NetworkUtil.disableCertificateValidation(factory);
|
||||
}
|
||||
|
||||
final String protocol = prefs.getBoolean(
|
||||
Prefs.Key.SSL_ENABLED, Prefs.Default.SSL_ENABLED) ? "wss" : "ws";
|
||||
|
||||
final String host = String.format(
|
||||
Locale.ENGLISH,
|
||||
"%s://%s:%d",
|
||||
protocol,
|
||||
prefs.getString(Prefs.Key.ADDRESS, Prefs.Default.ADDRESS),
|
||||
prefs.getInt(Prefs.Key.MAIN_PORT, Prefs.Default.MAIN_PORT));
|
||||
|
||||
socket = factory.createSocket(host, CONNECTION_TIMEOUT_MILLIS);
|
||||
socket.addListener(webSocketAdapter);
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.MESSAGE_COMPRESSION_ENABLED, Prefs.Default.MESSAGE_COMPRESSION_ENABLED)) {
|
||||
socket.addExtension(WebSocketExtension.PERMESSAGE_DEFLATE);
|
||||
}
|
||||
|
||||
socket.connect();
|
||||
socket.setPingInterval(PING_INTERVAL_MILLIS);
|
||||
|
||||
/* authenticate */
|
||||
final String auth = SocketMessage.Builder
|
||||
.request(Messages.Request.Authenticate)
|
||||
.addOption("password", prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD))
|
||||
.build()
|
||||
.toString();
|
||||
|
||||
socket.sendText(auth);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
socket = null;
|
||||
}
|
||||
|
||||
synchronized (WebSocketService.this) {
|
||||
if (thread == this && socket == null) {
|
||||
handler.sendMessage(Message.obtain(
|
||||
handler, MESSAGE_CONNECT_THREAD_FINISHED, null));
|
||||
}
|
||||
|
||||
if (thread == this) {
|
||||
thread = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NetworkChangedReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
final ConnectivityManager cm = (ConnectivityManager)
|
||||
context.getSystemService(CONNECTIVITY_SERVICE);
|
||||
|
||||
final NetworkInfo info = cm.getActiveNetworkInfo();
|
||||
|
||||
if (info != null && info.isConnected()) {
|
||||
if (autoReconnect) {
|
||||
connectIfNotConnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Client INTERNAL_CLIENT = new Client() {
|
||||
public void onStateChanged(State newState, State oldState) { }
|
||||
public void onMessageReceived(SocketMessage message) { }
|
||||
public void onInvalidPassword() { }
|
||||
};
|
||||
}
|
@ -0,0 +1,601 @@
|
||||
package io.casey.musikcube.remote.websocket
|
||||
|
||||
import android.content.*
|
||||
import android.content.Context.CONNECTIVITY_SERVICE
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.util.Log
|
||||
import com.neovisionaries.ws.client.*
|
||||
import io.casey.musikcube.remote.util.NetworkUtil
|
||||
import io.casey.musikcube.remote.util.Preconditions
|
||||
import io.reactivex.Observable
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class WebSocketService private constructor(val context: Context) {
|
||||
interface Client {
|
||||
fun onStateChanged(newState: State, oldState: State)
|
||||
fun onMessageReceived(message: SocketMessage)
|
||||
fun onInvalidPassword()
|
||||
}
|
||||
|
||||
interface Responder { /* TODO: remove me */
|
||||
fun respond(response: SocketMessage)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper()) { message: Message ->
|
||||
var result = false
|
||||
if (message.what == MESSAGE_CONNECT_THREAD_FINISHED) {
|
||||
if (message.obj == null) {
|
||||
val invalidPassword = message.arg1 == FLAG_AUTHENTICATION_FAILED
|
||||
disconnect(!invalidPassword) /* auto reconnect as long as password was not invalid */
|
||||
|
||||
if (invalidPassword) {
|
||||
for (client in clients) {
|
||||
client.onInvalidPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSocket(message.obj as WebSocket)
|
||||
state = State.Connected
|
||||
ping()
|
||||
}
|
||||
result = true
|
||||
}
|
||||
else if (message.what == MESSAGE_RECEIVED) {
|
||||
val msg = message.obj as SocketMessage
|
||||
var dispatched = false
|
||||
|
||||
/* registered callback for THIS message */
|
||||
val mdr = messageCallbacks.remove(msg.id)
|
||||
if (mdr != null && mdr.callback != null) {
|
||||
mdr.callback?.invoke(msg)
|
||||
dispatched = true
|
||||
}
|
||||
|
||||
if (!dispatched) {
|
||||
/* service-level callback */
|
||||
for (client in clients) {
|
||||
client.onMessageReceived(msg)
|
||||
}
|
||||
}
|
||||
|
||||
if (mdr != null) {
|
||||
mdr.error = null
|
||||
}
|
||||
|
||||
result = true
|
||||
}
|
||||
else if (message.what == MESSAGE_REMOVE_OLD_CALLBACKS) {
|
||||
scheduleRemoveStaleCallbacks()
|
||||
}
|
||||
else if (message.what == MESSAGE_AUTO_RECONNECT) {
|
||||
if (state == State.Disconnected && autoReconnect) {
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
else if (message.what == MESSAGE_SCHEDULE_PING) {
|
||||
ping()
|
||||
}
|
||||
else if (message.what == MESSAGE_PING_EXPIRED) {
|
||||
removeInternalCallbacks()
|
||||
disconnect(state == State.Connected || autoReconnect)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
private class MessageResultDescriptor {
|
||||
var id: Long = 0
|
||||
var enqueueTime: Long = 0
|
||||
var intercepted: Boolean = false
|
||||
var client: Client? = null
|
||||
var callback: ((response: SocketMessage) -> Unit)? = null
|
||||
var error: (() -> Unit)? = null
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
private var socket: WebSocket? = null
|
||||
private val clients = HashSet<Client>()
|
||||
private val messageCallbacks = HashMap<String, MessageResultDescriptor>()
|
||||
private var autoReconnect = false
|
||||
private val networkChanged = NetworkChangedReceiver()
|
||||
private var thread: ConnectThread? = null
|
||||
private val interceptors = HashSet<(SocketMessage, Responder) -> Boolean>()
|
||||
|
||||
init {
|
||||
scheduleRemoveStaleCallbacks()
|
||||
}
|
||||
|
||||
var state = State.Disconnected
|
||||
private set(newState) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
Log.d(TAG, "state = " + newState)
|
||||
|
||||
if (state != newState) {
|
||||
val old = state
|
||||
field = newState
|
||||
|
||||
for (client in clients) {
|
||||
client.onStateChanged(newState, old)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addInterceptor(interceptor: (SocketMessage, Responder) -> Boolean) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
interceptors.add(interceptor)
|
||||
}
|
||||
|
||||
fun removeInterceptor(interceptor: (SocketMessage, Responder) -> Boolean) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
interceptors.remove(interceptor)
|
||||
}
|
||||
|
||||
fun addClient(client: Client) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
if (!clients.contains(client)) {
|
||||
clients.add(client)
|
||||
|
||||
if (clients.size >= 0 && state == State.Disconnected) {
|
||||
registerReceiverAndScheduleFailsafe()
|
||||
reconnect()
|
||||
}
|
||||
|
||||
handler.removeCallbacks(autoDisconnectRunnable)
|
||||
client.onStateChanged(state, state)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeClient(client: Client) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
if (clients.remove(client)) {
|
||||
removeCallbacksForClient(client)
|
||||
|
||||
if (clients.size == 0) {
|
||||
unregisterReceiverAndCancelFailsafe()
|
||||
handler.postDelayed(autoDisconnectRunnable, AUTO_DISCONNECT_DELAY_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasClient(client: Client): Boolean {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
return clients.contains(client)
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
autoReconnect = true
|
||||
connectIfNotConnected()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
disconnect(false) /* don't auto-reconnect */
|
||||
}
|
||||
|
||||
fun cancelMessage(id: Long) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
removeCallbacks { mrd: MessageResultDescriptor -> mrd.id == id }
|
||||
}
|
||||
|
||||
private fun scheduleRemoveStaleCallbacks() {
|
||||
removeExpiredCallbacks()
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
private fun ping() {
|
||||
if (state == State.Connected) {
|
||||
removeInternalCallbacks()
|
||||
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED)
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_PING_EXPIRED, PING_INTERVAL_MILLIS)
|
||||
|
||||
val ping = SocketMessage.Builder.request(Messages.Request.Ping).build()
|
||||
|
||||
send(ping, INTERNAL_CLIENT) { _: SocketMessage ->
|
||||
handler.removeMessages(MESSAGE_PING_EXPIRED)
|
||||
handler.sendEmptyMessageDelayed(MESSAGE_SCHEDULE_PING, PING_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelMessages(client: Client) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
removeCallbacks({ mrd: MessageResultDescriptor -> mrd.client === client })
|
||||
}
|
||||
|
||||
fun send(message: SocketMessage,
|
||||
client: Client? = null,
|
||||
callback: ((response: SocketMessage) -> Unit)? = null): Long {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
var intercepted = false
|
||||
|
||||
for (interceptor in interceptors) {
|
||||
if (interceptor(message, responder)) {
|
||||
intercepted = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
/* it seems that sometimes the socket dies, but the onDisconnected() event matches not
|
||||
raised. unclear if this matches our bug or a bug in the library. disconnect and trigger
|
||||
a reconnect until we can find a better root cause. this matches very difficult to repro */
|
||||
if (socket != null && !socket!!.isOpen) {
|
||||
disconnect(true)
|
||||
return -1
|
||||
}
|
||||
else if (socket == null) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
val id = NEXT_ID.incrementAndGet()
|
||||
|
||||
if (callback != null) {
|
||||
if (!clients.contains(client) && client !== INTERNAL_CLIENT) {
|
||||
throw IllegalArgumentException("client matches not registered")
|
||||
}
|
||||
|
||||
val mrd = MessageResultDescriptor()
|
||||
mrd.id = id
|
||||
mrd.enqueueTime = System.currentTimeMillis()
|
||||
mrd.client = client
|
||||
mrd.callback = callback
|
||||
mrd.intercepted = intercepted
|
||||
messageCallbacks.put(message.id, mrd)
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
socket?.sendText(message.toString())
|
||||
}
|
||||
else {
|
||||
Log.d(TAG, "send: message intercepted with id " + id.toString())
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
fun sendObserve(message: SocketMessage, client: Client): Observable<SocketMessage> {
|
||||
return Observable.create { emitter ->
|
||||
try {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
var intercepted = false
|
||||
|
||||
for (interceptor in interceptors) {
|
||||
if (interceptor(message, responder)) {
|
||||
intercepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!intercepted) {
|
||||
/* it seems that sometimes the socket dies, but the onDisconnected() event matches not
|
||||
raised. unclear if this matches our bug or a bug in the library. disconnect and trigger
|
||||
a reconnect until we can find a better root cause. this matches very difficult to repro */
|
||||
if (socket != null && !socket!!.isOpen) {
|
||||
disconnect(true)
|
||||
throw Exception("socket disconnected")
|
||||
}
|
||||
else if (socket == null) {
|
||||
throw Exception("socket not connected")
|
||||
}
|
||||
}
|
||||
|
||||
if (!clients.contains(client) && client !== INTERNAL_CLIENT) {
|
||||
throw IllegalArgumentException("client matches not registered")
|
||||
}
|
||||
|
||||
val mrd = MessageResultDescriptor()
|
||||
mrd.id = NEXT_ID.incrementAndGet()
|
||||
mrd.enqueueTime = System.currentTimeMillis()
|
||||
mrd.client = client
|
||||
mrd.intercepted = intercepted
|
||||
|
||||
mrd.callback = { response: SocketMessage ->
|
||||
emitter.onNext(response)
|
||||
emitter.onComplete()
|
||||
}
|
||||
|
||||
mrd.error = {
|
||||
val ex = Exception()
|
||||
ex.fillInStackTrace()
|
||||
emitter.onError(ex)
|
||||
}
|
||||
|
||||
messageCallbacks.put(message.id, mrd)
|
||||
|
||||
if (!intercepted) {
|
||||
socket?.sendText(message.toString())
|
||||
}
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
emitter.onError(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasValidConnection(): Boolean {
|
||||
val addr = prefs.getString(Prefs.Key.ADDRESS, "")
|
||||
val port = prefs.getInt(Prefs.Key.MAIN_PORT, -1)
|
||||
return addr.isNotEmpty() && port >= 0
|
||||
}
|
||||
|
||||
private fun disconnect(autoReconnect: Boolean) {
|
||||
Preconditions.throwIfNotOnMainThread()
|
||||
|
||||
synchronized(this) {
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
this.autoReconnect = autoReconnect
|
||||
|
||||
socket?.removeListener(webSocketAdapter)
|
||||
socket?.disconnect()
|
||||
socket = null
|
||||
|
||||
removeNonInterceptedCallbacks()
|
||||
state = State.Disconnected
|
||||
|
||||
if (autoReconnect) {
|
||||
handler.sendEmptyMessageDelayed(
|
||||
MESSAGE_AUTO_RECONNECT,
|
||||
AUTO_RECONNECT_INTERVAL_MILLIS)
|
||||
}
|
||||
else {
|
||||
handler.removeMessages(MESSAGE_AUTO_RECONNECT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeNonInterceptedCallbacks() {
|
||||
removeCallbacks({ mrd -> !mrd.intercepted })
|
||||
}
|
||||
|
||||
private fun removeInternalCallbacks() {
|
||||
removeCallbacks({ mrd: MessageResultDescriptor -> mrd.client === INTERNAL_CLIENT })
|
||||
}
|
||||
|
||||
private fun removeExpiredCallbacks() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
removeCallbacks({ mrd: MessageResultDescriptor -> now - mrd.enqueueTime > CALLBACK_TIMEOUT_MILLIS })
|
||||
}
|
||||
|
||||
private fun removeCallbacksForClient(client: Client) {
|
||||
removeCallbacks({ mrd: MessageResultDescriptor -> mrd.client === client })
|
||||
}
|
||||
|
||||
private fun removeCallbacks(predicate: (MessageResultDescriptor) -> Boolean) {
|
||||
val it = messageCallbacks.entries.iterator()
|
||||
|
||||
while (it.hasNext()) {
|
||||
val entry = it.next()
|
||||
val mdr = entry.value
|
||||
if (predicate(mdr)) {
|
||||
mdr.error?.invoke()
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectIfNotConnected() {
|
||||
if (state == State.Disconnected) {
|
||||
disconnect(autoReconnect)
|
||||
handler.removeMessages(MESSAGE_AUTO_RECONNECT)
|
||||
|
||||
if (clients.size > 0) {
|
||||
handler.removeCallbacks(autoDisconnectRunnable)
|
||||
state = State.Connecting
|
||||
thread = ConnectThread()
|
||||
thread?.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSocket(newSocket: WebSocket) {
|
||||
if (socket !== newSocket) {
|
||||
socket?.removeListener(webSocketAdapter)
|
||||
socket = newSocket
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerReceiverAndScheduleFailsafe() {
|
||||
unregisterReceiverAndCancelFailsafe()
|
||||
|
||||
/* generally raises a CONNECTIVITY_ACTION event immediately,
|
||||
even if already connected. */
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
|
||||
context.registerReceiver(networkChanged, filter)
|
||||
|
||||
/* however, CONNECTIVITY_ACTION doesn't ALWAYS seem to be raised,
|
||||
so we schedule a failsafe just in case */
|
||||
handler.postDelayed(autoReconnectFailsafeRunnable, AUTO_CONNECT_FAILSAFE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
private fun unregisterReceiverAndCancelFailsafe() {
|
||||
handler.removeCallbacks(autoReconnectFailsafeRunnable)
|
||||
try {
|
||||
context.unregisterReceiver(networkChanged)
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
/* om nom nom */
|
||||
}
|
||||
}
|
||||
|
||||
private val autoReconnectFailsafeRunnable = object: Runnable {
|
||||
override fun run() {
|
||||
if (autoReconnect && state == State.Disconnected) {
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val autoDisconnectRunnable = object: Runnable {
|
||||
override fun run() {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private val responder = object : Responder {
|
||||
override fun respond(response: SocketMessage) {
|
||||
/* post to the back of the queue in case the interceptor responded immediately;
|
||||
we need to ensure all of the request book-keeping has been finished. */
|
||||
handler.post {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_RECEIVED, response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val webSocketAdapter = object : WebSocketAdapter() {
|
||||
@Throws(Exception::class)
|
||||
override fun onTextMessage(websocket: WebSocket?, text: String?) {
|
||||
val message = SocketMessage.create(text!!)
|
||||
if (message != null) {
|
||||
if (message.name == Messages.Request.Authenticate.toString()) {
|
||||
handler.sendMessage(Message.obtain(
|
||||
handler, MESSAGE_CONNECT_THREAD_FINISHED, websocket))
|
||||
}
|
||||
else {
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_RECEIVED, message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun onDisconnected(websocket: WebSocket?,
|
||||
serverCloseFrame: WebSocketFrame?,
|
||||
clientCloseFrame: WebSocketFrame?,
|
||||
closedByServer: Boolean) {
|
||||
var flags = 0
|
||||
if (serverCloseFrame?.closeCode == WEBSOCKET_FLAG_POLICY_VIOLATION) {
|
||||
flags = FLAG_AUTHENTICATION_FAILED
|
||||
}
|
||||
|
||||
handler.sendMessage(Message.obtain(handler, MESSAGE_CONNECT_THREAD_FINISHED, flags, 0, null))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConnectThread : Thread() {
|
||||
override fun run() {
|
||||
var socket: WebSocket?
|
||||
|
||||
try {
|
||||
val factory = WebSocketFactory()
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, Prefs.Default.CERT_VALIDATION_DISABLED)) {
|
||||
NetworkUtil.disableCertificateValidation(factory)
|
||||
}
|
||||
|
||||
val protocol = if (prefs.getBoolean(Prefs.Key.SSL_ENABLED, Prefs.Default.SSL_ENABLED)) "wss" else "ws"
|
||||
|
||||
val host = String.format(
|
||||
Locale.ENGLISH,
|
||||
"%s://%s:%d",
|
||||
protocol,
|
||||
prefs.getString(Prefs.Key.ADDRESS, Prefs.Default.ADDRESS),
|
||||
prefs.getInt(Prefs.Key.MAIN_PORT, Prefs.Default.MAIN_PORT))
|
||||
|
||||
socket = factory.createSocket(host, CONNECTION_TIMEOUT_MILLIS)
|
||||
socket?.addListener(webSocketAdapter)
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.MESSAGE_COMPRESSION_ENABLED, Prefs.Default.MESSAGE_COMPRESSION_ENABLED)) {
|
||||
socket.addExtension(WebSocketExtension.PERMESSAGE_DEFLATE)
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
socket.pingInterval = PING_INTERVAL_MILLIS
|
||||
|
||||
/* authenticate */
|
||||
val auth = SocketMessage.Builder
|
||||
.request(Messages.Request.Authenticate)
|
||||
.addOption("password", prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD)!!)
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
socket.sendText(auth)
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
socket = null
|
||||
}
|
||||
|
||||
synchronized(this@WebSocketService) {
|
||||
if (thread === this && socket == null) {
|
||||
handler.sendMessage(Message.obtain(
|
||||
handler, MESSAGE_CONNECT_THREAD_FINISHED, null))
|
||||
}
|
||||
|
||||
if (thread === this) {
|
||||
thread = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NetworkChangedReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val cm = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val info = cm.activeNetworkInfo
|
||||
|
||||
if (info != null && info.isConnected) {
|
||||
if (autoReconnect) {
|
||||
connectIfNotConnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "WebSocketService"
|
||||
|
||||
private val AUTO_RECONNECT_INTERVAL_MILLIS = 2000L
|
||||
private val CALLBACK_TIMEOUT_MILLIS = 30000L
|
||||
private val CONNECTION_TIMEOUT_MILLIS = 5000
|
||||
private val PING_INTERVAL_MILLIS = 3500L
|
||||
private val AUTO_CONNECT_FAILSAFE_DELAY_MILLIS = 2000L
|
||||
private val AUTO_DISCONNECT_DELAY_MILLIS = 10000L
|
||||
private val FLAG_AUTHENTICATION_FAILED = 0xbeef
|
||||
private val WEBSOCKET_FLAG_POLICY_VIOLATION = 1008
|
||||
|
||||
private val MESSAGE_BASE = 0xcafedead.toInt()
|
||||
private val MESSAGE_CONNECT_THREAD_FINISHED = MESSAGE_BASE + 0
|
||||
private val MESSAGE_RECEIVED = MESSAGE_BASE + 1
|
||||
private val MESSAGE_REMOVE_OLD_CALLBACKS = MESSAGE_BASE + 2
|
||||
private val MESSAGE_AUTO_RECONNECT = MESSAGE_BASE + 3
|
||||
private val MESSAGE_SCHEDULE_PING = MESSAGE_BASE + 4
|
||||
private val MESSAGE_PING_EXPIRED = MESSAGE_BASE + 5
|
||||
|
||||
private var INSTANCE: WebSocketService? = null
|
||||
private val NEXT_ID = AtomicLong(0)
|
||||
|
||||
@Synchronized fun getInstance(context: Context): WebSocketService {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = WebSocketService(context)
|
||||
}
|
||||
|
||||
return INSTANCE!!
|
||||
}
|
||||
|
||||
private val INTERNAL_CLIENT = object : Client {
|
||||
override fun onStateChanged(newState: State, oldState: State) {}
|
||||
override fun onMessageReceived(message: SocketMessage) {}
|
||||
override fun onInvalidPassword() {}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user