Converted remaining Java code to Kotlin. Not everything is completely

idiomatic yet, but this is a great start.
This commit is contained in:
casey langen 2017-06-20 21:10:22 -07:00
parent bfbd4db5e5
commit e86cec10ea
76 changed files with 6475 additions and 7048 deletions

View File

@ -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'

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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)
}
}
}

View File

@ -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();
}
}

View File

@ -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()
}
}

View File

@ -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");
}
}
}

View File

@ -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")
}
}
}

View File

@ -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 -> { }
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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))
}
}
}
}
}

View File

@ -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() {
}
}

View File

@ -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"
}
}
}

View File

@ -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);
}

View File

@ -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
}

View File

@ -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);
}
}
}

View File

@ -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)
}
}
}

View File

@ -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");
}
}

View File

@ -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")
}
}
}

View File

@ -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()
}

View File

@ -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() {
}
};
}

View File

@ -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
}
}

View File

@ -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");
}
}

View File

@ -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")
}
}
}

View File

@ -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
}
}
}

View File

@ -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();
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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();
}
}
}
};
}

View File

@ -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))
}
}
}

View File

@ -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();
}
}
}

View File

@ -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)
}
}
}

View File

@ -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();
}
}
}

View File

@ -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)
}
}
}

View File

@ -1,5 +0,0 @@
package io.casey.musikcube.remote.ui.activity;
public interface Filterable {
void setFilter(final String filter);
}

View File

@ -0,0 +1,5 @@
package io.casey.musikcube.remote.ui.activity
interface Filterable {
fun setFilter(filter: String)
}

View File

@ -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();
}
}
}

View File

@ -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)
}
}
}

View File

@ -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;
}
}
}

View File

@ -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)
}
}
}

View File

@ -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) {
}
};
}

View File

@ -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
}
}
}

View File

@ -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();
}

View File

@ -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)?
}

View File

@ -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
}

View File

@ -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();
}
}

View File

@ -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()
}
}
}

View File

@ -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);
}
}
};
}

View File

@ -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()
}
}
}

View File

@ -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();
}
}

View File

@ -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 <= ' ' }
}
}
}

View File

@ -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() {
}
};
}

View File

@ -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
}
}

View File

@ -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() {
}
}

View File

@ -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

View File

@ -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();
}
}
});
};
}

View File

@ -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()
}
}
}
}
}
}

View File

@ -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);
};
}

View File

@ -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)
}
}
}

View File

@ -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);
}
}

View File

@ -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)
}
}

View File

@ -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;
}
}

View File

@ -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
}
}
}

View File

@ -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() {
}
}

View File

@ -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()
}
})
}

View File

@ -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");
}
}
}

View File

@ -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")
}
}
}

View File

@ -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);
}
}

View File

@ -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()
}
}

View File

@ -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";
}
}

View File

@ -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"
}
}
}

View File

@ -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;
}
}

View File

@ -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"
}
}

View File

@ -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);
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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() { }
};
}

View File

@ -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() {}
}
}
}