Added support for streaming playback from musikbox server -> musikdroid

Android app. Summary of required changes:

1. Removed TransportModel and related classes, created PlaybackService
interface. Old TransportModel code now lives in RemotePlaybackService.

2. Added StreamingPlaybackService implementation of PlaybackService.
Uses MediaPlayer or ExoPlayer to stream audio via HTTP.

3. Added SystemService that is used by StreamingPlaybackService. It's a
real Android service that manages notifications, wake locks, media
buttons, and other system integrations.

4. Added RxJava and RxAndroid. Use them in StreamingPlaybackService to
keep logic as sane as possible.

5. Added playback mode selection to settings (streaming vs remote). Also
added a field for streaming audio port.

6. Added "ids_only" request type to WebSocketServer when requesting list of
tracks.
This commit is contained in:
casey langen 2017-04-29 21:15:10 -07:00
parent 3941485d23
commit 78ce185471
46 changed files with 3142 additions and 680 deletions

View File

@ -153,3 +153,7 @@ networking:
* [libressl](https://www.libressl.org/)
* [nv-websocket-client](https://github.com/TakahikoKawasaki/nv-websocket-client)
* [okhttp](http://square.github.io/okhttp/)
miscellaneous
* [rxjava](https://github.com/ReactiveX/RxJava)
* [rxandroid](https://github.com/ReactiveX/RxAndroid)

View File

@ -90,6 +90,7 @@ namespace key {
static const std::string limit = "limit";
static const std::string offset = "offset";
static const std::string count_only = "count_only";
static const std::string ids_only = "ids_only";
static const std::string count = "count";
static const std::string success = "success";
static const std::string index = "index";

View File

@ -394,6 +394,7 @@ bool WebSocketServer::RespondWithTracks(
{
json& options = request[message::options];
bool countOnly = options.value(key::count_only, false);
bool idsOnly = options.value(key::ids_only, false);
if (tracks) {
if (countOnly) {
@ -409,10 +410,16 @@ bool WebSocketServer::RespondWithTracks(
else {
json data = json::array();
IRetainedTrack* track;
for (int i = 0; i < (int)tracks->Count(); i++) {
track = tracks->GetRetainedTrack((size_t)i);
data.push_back(this->ReadTrackMetadata(track));
IRetainedTrack* track = nullptr;
for (size_t i = 0; i < tracks->Count(); i++) {
if (idsOnly) {
data.push_back({ {key::id, tracks->GetId(i) } });
}
else {
track = tracks->GetRetainedTrack(i);
data.push_back(this->ReadTrackMetadata(track));
}
track->Release();
}

View File

@ -44,6 +44,9 @@ dependencies {
compile 'com.neovisionaries:nv-websocket-client:1.31'
compile 'com.squareup.okhttp3:okhttp:3.6.0'
compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'io.reactivex.rxjava2:rxjava:2.0.9'
compile 'io.reactivex.rxjava2:rxandroid:2.0.1'
compile 'com.google.android.exoplayer:exoplayer:r2.4.0'
compile 'com.android.support:appcompat-v7:25.1.1'
compile 'com.android.support:recyclerview-v7:25.1.1'

View File

@ -5,11 +5,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".Application"
android:supportsRtl="true"
android:theme="@style/AppTheme" >
@ -22,19 +25,19 @@
</intent-filter>
</activity>
<activity android:name=".AlbumBrowseActivity"
<activity android:name=".ui.activity.AlbumBrowseActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".SettingsActivity"
<activity android:name=".ui.activity.SettingsActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity android:name=".PlayQueueActivity"
<activity android:name=".ui.activity.PlayQueueActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".TrackListActivity"
<activity android:name=".ui.activity.TrackListActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
@ -48,10 +51,23 @@
</activity>
<activity android:name=".CategoryBrowseActivity"
<activity android:name=".ui.activity.CategoryBrowseActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<service android:name=".playback.SystemService">
<intent-filter>
<action android:name="io.casey.musikcube.remote.WAKE_UP" />
<action android:name="io.casey.musikcube.remote.SHUT_DOWN" />
</intent-filter>
</service>
<receiver android:name=".playback.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,15 @@
package io.casey.musikcube.remote;
public class Application extends android.app.Application {
private static Application instance;
@Override
public void onCreate() {
super.onCreate();
instance = this;
}
public static Application getInstance() {
return instance;
}
}

View File

@ -31,11 +31,29 @@ import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
import io.casey.musikcube.remote.playback.Metadata;
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.model.AlbumArtModel;
import io.casey.musikcube.remote.ui.util.Views;
import io.casey.musikcube.remote.ui.view.LongPressTextView;
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 MainActivity extends WebSocketActivityBase {
private static Map<TransportModel.RepeatMode, Integer> REPEAT_TO_STRING_ID;
private static Map<RepeatMode, Integer> REPEAT_TO_STRING_ID;
private WebSocketService wss = null;
private TransportModel model = new TransportModel();
private SharedPreferences prefs;
private TextView title, artist, album, playPause, volume;
@ -45,6 +63,7 @@ public class MainActivity extends WebSocketActivityBase {
private CheckBox shuffleCb, muteCb, repeatCb;
private View disconnectedOverlay;
private Handler handler = new Handler();
private PlaybackService playback;
/* ugh, artwork related */
private enum DisplayMode { Artwork, NoArtwork, Stopped }
@ -55,9 +74,9 @@ public class MainActivity extends WebSocketActivityBase {
static {
REPEAT_TO_STRING_ID = new HashMap<>();
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.None, R.string.button_repeat_off);
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.List, R.string.button_repeat_list);
REPEAT_TO_STRING_ID.put(TransportModel.RepeatMode.Track, R.string.button_repeat_track);
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) {
@ -71,6 +90,7 @@ public class MainActivity extends WebSocketActivityBase {
this.prefs = this.getSharedPreferences("prefs", Context.MODE_PRIVATE);
this.wss = getWebSocketService();
this.playback = getPlaybackService();
setContentView(R.layout.activity_main);
bindEventListeners();
@ -89,6 +109,7 @@ public class MainActivity extends WebSocketActivityBase {
@Override
protected void onResume() {
super.onResume();
this.playback = getPlaybackService();
bindCheckBoxEventListeners();
rebindUi();
}
@ -137,6 +158,11 @@ public class MainActivity extends WebSocketActivityBase {
return this.serviceClient;
}
@Override
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
return this.playbackEvents;
}
private void bindCheckBoxEventListeners() {
this.shuffleCb.setOnCheckedChangeListener(shuffleListener);
this.muteCb.setOnCheckedChangeListener(muteListener);
@ -177,53 +203,30 @@ public class MainActivity extends WebSocketActivityBase {
this.mainTrackMetadataNoAlbumArt.setAlpha(0.0f);
this.mainTrackMetadataWithAlbumArt.setAlpha(0.0f);
findViewById(R.id.button_prev).setOnClickListener((View view) ->
wss.send(SocketMessage.Builder.request(
Messages.Request.Previous).build()));
findViewById(R.id.button_prev).setOnClickListener((View view) -> playback.prev());
final LongPressTextView seekBack = (LongPressTextView) findViewById(R.id.button_seek_back);
seekBack.setOnTickListener((View view) ->
wss.send(SocketMessage.Builder
.request(Messages.Request.SeekRelative)
.addOption(Messages.Key.DELTA, -5.0f).build()));
seekBack.setOnTickListener((View view) -> playback.seekBackward());
findViewById(R.id.button_play_pause).setOnClickListener((View view) -> {
if (model.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
wss.send(SocketMessage.Builder.request(
Messages.Request.PlayAllTracks).build());
if (playback.getPlaybackState() == PlaybackState.Stopped) {
playback.playAll();
}
else {
wss.send(SocketMessage.Builder.request(
Messages.Request.PauseOrResume).build());
playback.pauseOrResume();
}
});
findViewById(R.id.button_next).setOnClickListener((View view) -> {
wss.send(SocketMessage.Builder.request(
Messages.Request.Next).build());
});
findViewById(R.id.button_next).setOnClickListener((View view) -> playback.next());
final LongPressTextView seekForward = (LongPressTextView) findViewById(R.id.button_seek_forward);
seekForward.setOnTickListener((View view) ->
wss.send(SocketMessage.Builder
.request(Messages.Request.SeekRelative)
.addOption(Messages.Key.DELTA, 5.0f).build()));
seekForward.setOnTickListener((View view) -> playback.seekForward());
final LongPressTextView volumeUp = (LongPressTextView) findViewById(R.id.button_vol_up);
volumeUp.setOnTickListener((View view) -> {
wss.send(SocketMessage.Builder
.request(Messages.Request.SetVolume)
.addOption(Messages.Key.RELATIVE, Messages.Value.UP)
.build());
});
volumeUp.setOnTickListener((View view) -> playback.volumeUp());
final LongPressTextView volumeDown = (LongPressTextView) findViewById(R.id.button_vol_down);
volumeDown.setOnTickListener((View view) -> {
wss.send(SocketMessage.Builder
.request(Messages.Request.SetVolume)
.addOption(Messages.Key.RELATIVE, Messages.Value.DOWN)
.build());
});
volumeDown.setOnTickListener((View view) -> playback.volumeDown());
notPlayingOrDisconnected.setOnClickListener((view) -> {
if (wss.getState() != WebSocketService.State.Connected) {
@ -246,7 +249,7 @@ public class MainActivity extends WebSocketActivityBase {
findViewById(R.id.button_play_queue).setOnClickListener((view) -> navigateToPlayQueue());
findViewById(R.id.metadata_container).setOnClickListener((view) -> {
if (model.getQueueCount() > 0) {
if (playback.getQueueCount() > 0) {
navigateToPlayQueue();
}
});
@ -260,8 +263,8 @@ public class MainActivity extends WebSocketActivityBase {
}
private void rebindAlbumArtistWithArtTextView() {
final String artist = model.getTrackValueString(TransportModel.Key.ARTIST, getString(R.string.unknown_artist));
final String album = model.getTrackValueString(TransportModel.Key.ALBUM, getString(R.string.unknown_album));
final String artist = playback.getTrackString(Metadata.Track.ARTIST, getString(R.string.unknown_artist));
final String album = playback.getTrackString(Metadata.Track.ALBUM, getString(R.string.unknown_album));
final ForegroundColorSpan albumColor =
new ForegroundColorSpan(getResources().getColor(R.color.theme_orange));
@ -307,20 +310,25 @@ public class MainActivity extends WebSocketActivityBase {
}
private void rebindUi() {
if (this.playback == null) {
throw new IllegalStateException();
}
/* state management for UI stuff is starting to get out of hand. we should
refactor things pretty soon before they're completely out of control */
final boolean streaming = prefs.getBoolean("streaming_playback", false);
final WebSocketService.State state = wss.getState();
final boolean connected = state == WebSocketService.State.Connected;
final boolean playing = (model.getPlaybackState() == TransportModel.PlaybackState.Playing);
final boolean playing = (playback.getPlaybackState() == PlaybackState.Playing);
this.playPause.setText(playing ? R.string.button_pause : R.string.button_play);
final boolean stopped = (model.getPlaybackState() == TransportModel.PlaybackState.Stopped);
final boolean stopped = (playback.getPlaybackState() == PlaybackState.Stopped);
notPlayingOrDisconnected.setVisibility(stopped ? View.VISIBLE : View.GONE);
final boolean stateIsValidForArtwork = !stopped && connected && model.isValid();
final boolean stateIsValidForArtwork = !stopped && connected && playback.getQueueCount() > 0;
this.connected.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE);
this.disconnectedOverlay.setVisibility(connected ? View.GONE : View.VISIBLE);
@ -337,10 +345,10 @@ public class MainActivity extends WebSocketActivityBase {
notPlayingOrDisconnected.setVisibility(View.GONE);
}
final String artist = model.getTrackValueString(TransportModel.Key.ARTIST, "");
final String album = model.getTrackValueString(TransportModel.Key.ALBUM, "");
final String title = model.getTrackValueString(TransportModel.Key.TITLE, "");
final String volume = getString(R.string.status_volume, Math.round(model.getVolume() * 100));
final String artist = playback.getTrackString(Metadata.Track.ARTIST, "");
final String album = playback.getTrackString(Metadata.Track.ALBUM, "");
final String title = playback.getTrackString(Metadata.Track.TITLE, "");
final String volume = getString(R.string.status_volume, Math.round(playback.getVolume() * 100));
this.title.setText(Strings.empty(title) ? getString(R.string.unknown_title) : title);
this.artist.setText(Strings.empty(artist) ? getString(R.string.unknown_artist) : artist);
@ -351,13 +359,15 @@ public class MainActivity extends WebSocketActivityBase {
this.titleWithArt.setText(Strings.empty(title) ? getString(R.string.unknown_title) : title);
this.volumeWithArt.setText(volume);
final TransportModel.RepeatMode repeatMode = model.getRepeatMode();
final boolean repeatChecked = (repeatMode != TransportModel.RepeatMode.None);
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);
Views.setCheckWithoutEvent(this.shuffleCb, model.isShuffled(), this.shuffleListener);
Views.setCheckWithoutEvent(this.muteCb, model.isMuted(), this.muteListener);
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);
boolean albumArtEnabledInSettings = this.prefs.getBoolean("album_art_enabled", true);
@ -378,7 +388,6 @@ public class MainActivity extends WebSocketActivityBase {
}
private void clearUi() {
model.reset();
albumArtModel = new AlbumArtModel();
updateAlbumArt();
rebindUi();
@ -409,7 +418,7 @@ public class MainActivity extends WebSocketActivityBase {
private void preloadNextImage() {
final SocketMessage request = SocketMessage.Builder
.request(Messages.Request.QueryPlayQueueTracks)
.addOption(Messages.Key.OFFSET, this.model.getQueuePosition() + 1)
.addOption(Messages.Key.OFFSET, this.playback.getQueuePosition() + 1)
.addOption(Messages.Key.LIMIT, 1)
.build();
@ -417,8 +426,8 @@ public class MainActivity extends WebSocketActivityBase {
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA, new JSONArray());
if (data.length() > 0) {
JSONObject track = data.optJSONObject(0);
final String artist = track.optString(TransportModel.Key.ARTIST, "");
final String album = track.optString(TransportModel.Key.ALBUM, "");
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, (info, url) -> {
@ -432,7 +441,7 @@ public class MainActivity extends WebSocketActivityBase {
}
private void updateAlbumArt() {
if (model.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
if (playback.getPlaybackState() == PlaybackState.Stopped) {
setMetadataDisplayMode(DisplayMode.NoArtwork);
}
@ -485,25 +494,25 @@ public class MainActivity extends WebSocketActivityBase {
}
private void navigateToCurrentArtist() {
final long artistId = model.getTrackValueLong(TransportModel.Key.ARTIST_ID, -1);
final long artistId = playback.getTrackLong(Metadata.Track.ARTIST_ID, -1);
if (artistId != -1) {
final String artistName = model.getTrackValueString(TransportModel.Key.ARTIST, "");
final String artistName = playback.getTrackString(Metadata.Track.ARTIST, "");
startActivity(AlbumBrowseActivity.getStartIntent(
MainActivity.this, Messages.Category.ARTIST, artistId, artistName));
}
}
private void navigateToCurrentAlbum() {
final long albumId = model.getTrackValueLong(TransportModel.Key.ALBUM_ID, -1);
final long albumId = playback.getTrackLong(Metadata.Track.ALBUM_ID, -1);
if (albumId != -1) {
final String albumName = model.getTrackValueString(TransportModel.Key.ALBUM, "");
final String albumName = playback.getTrackString(Metadata.Track.ALBUM, "");
startActivity(TrackListActivity.getStartIntent(
MainActivity.this, Messages.Category.ALBUM, albumId, albumName));
}
}
private void navigateToPlayQueue() {
startActivity(PlayQueueActivity.getStartIntent(MainActivity.this, model.getQueuePosition()));
startActivity(PlayQueueActivity.getStartIntent(MainActivity.this, playback.getQueuePosition()));
}
private AlbumArtModel.AlbumArtCallback albumArtRetrieved = (model, url) -> {
@ -521,41 +530,44 @@ public class MainActivity extends WebSocketActivityBase {
private CheckBox.OnCheckedChangeListener muteListener =
(CompoundButton compoundButton, boolean b) -> {
if (b != model.isMuted()) {
wss.send(SocketMessage.Builder
.request(Messages.Request.ToggleMute).build());
if (b != playback.isMuted()) {
playback.toggleMute();
}
};
private CheckBox.OnCheckedChangeListener shuffleListener =
(CompoundButton compoundButton, boolean b) -> {
if (b != model.isShuffled()) {
wss.send(SocketMessage.Builder
.request(Messages.Request.ToggleShuffle).build());
if (b != playback.isShuffled()) {
playback.toggleShuffle();
}
};
final CheckBox.OnCheckedChangeListener repeatListener = new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
final TransportModel.RepeatMode currentMode = model.getRepeatMode();
final RepeatMode currentMode = playback.getRepeatMode();
TransportModel.RepeatMode newMode = TransportModel.RepeatMode.None;
RepeatMode newMode = RepeatMode.None;
if (currentMode == TransportModel.RepeatMode.None) {
newMode = TransportModel.RepeatMode.List;
if (currentMode == RepeatMode.None) {
newMode = RepeatMode.List;
}
else if (currentMode == TransportModel.RepeatMode.List) {
newMode = TransportModel.RepeatMode.Track;
else if (currentMode == RepeatMode.List) {
newMode = RepeatMode.Track;
}
final boolean checked = (newMode != TransportModel.RepeatMode.None);
final boolean checked = (newMode != RepeatMode.None);
compoundButton.setText(REPEAT_TO_STRING_ID.get(newMode));
Views.setCheckWithoutEvent(repeatCb, checked, this);
wss.send(SocketMessage.Builder
.request(Messages.Request.ToggleRepeat)
.build());
playback.toggleRepeatMode();
}
};
private PlaybackService.EventListener playbackEvents = new PlaybackService.EventListener() {
@Override
public void onStateUpdated() {
rebindUi();
}
};
@ -563,9 +575,6 @@ public class MainActivity extends WebSocketActivityBase {
@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());
rebindUi();
}
else if (newState == WebSocketService.State.Disconnected) {
@ -575,11 +584,6 @@ public class MainActivity extends WebSocketActivityBase {
@Override
public void onMessageReceived(SocketMessage message) {
if (model.canHandle(message)) {
if (model.update(message)) {
rebindUi();
}
}
}
@Override

View File

@ -1,190 +0,0 @@
package io.casey.musikcube.remote;
import android.support.v7.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class TrackListScrollCache<TrackType> {
private static final int MAX_SIZE = 150;
public static final int DEFAULT_WINDOW_SIZE = 75 ;
private int count = 0;
private RecyclerView recyclerView;
private RecyclerView.Adapter<?> adapter;
private WebSocketService wss;
private Mapper<TrackType> mapper;
private QueryFactory queryFactory;
private int scrollState = RecyclerView.SCROLL_STATE_IDLE;
private int queryOffset = -1, queryLimit = -1;
private int initialPosition = -1;
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 QueryFactory {
SocketMessage getRequeryMessage();
SocketMessage getPageAroundMessage(int offset, int limit);
}
public TrackListScrollCache(RecyclerView recyclerView,
RecyclerView.Adapter<?> adapter,
WebSocketService wss,
QueryFactory queryFactory,
Mapper<TrackType> mapper) {
this.recyclerView = recyclerView;
this.adapter = adapter;
this.wss = wss;
this.queryFactory = queryFactory;
this.mapper = mapper;
}
public void requery() {
if (connected) {
cancelMessages();
final SocketMessage message = queryFactory.getRequeryMessage();
wss.send(message, this.client, (SocketMessage response) -> {
setCount(response.getIntOption(Messages.Key.COUNT, 0));
if (initialPosition != -1) {
recyclerView.scrollToPosition(initialPosition);
initialPosition = -1;
}
});
}
}
public void pause() {
connected = false;
this.recyclerView.removeOnScrollListener(scrollListener);
this.wss.removeClient(this.client);
}
public void resume() {
this.recyclerView.addOnScrollListener(scrollListener);
this.wss.addClient(this.client);
connected = true;
}
public void setInitialPosition(int initialIndex) {
this.initialPosition = initialIndex;
}
public void setCount(int count) {
this.count = count;
invalidateCache();
cancelMessages();
adapter.notifyDataSetChanged();
}
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) {
return;
}
if (index >= queryOffset && index <= queryOffset + queryLimit) {
return; /* already in flight */
}
cancelMessages();
queryOffset = Math.max(0, index - 10); /* snag a couple before */
queryLimit = DEFAULT_WINDOW_SIZE;
SocketMessage request = this.queryFactory.getPageAroundMessage(queryOffset, queryLimit);
this.wss.send(request, this.client, (SocketMessage response) -> {
this.queryOffset = this.queryLimit = -1;
final JSONArray data = response.getJsonArrayOption(Messages.Key.DATA);
final int offset = 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(offset + i, entry);
}
}
adapter.notifyDataSetChanged();
}
});
}
private RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
scrollState = newState;
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
adapter.notifyDataSetChanged();
}
}
};
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

@ -1,216 +0,0 @@
package io.casey.musikcube.remote;
import org.json.JSONObject;
public class TransportModel {
public 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";
String TITLE = "title";
String ALBUM = "album";
String ARTIST = "artist";
String ALBUM_ID = "album_id";
String ARTIST_ID = "visual_artist_id";
}
public enum PlaybackState {
Unknown("unknown"),
Stopped("stopped"),
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)) {
return Stopped;
}
else if (Playing.rawValue.equals(rawValue)) {
return Playing;
}
else if (Paused.rawValue.equals(rawValue)) {
return Paused;
}
throw new IllegalArgumentException("rawValue is invalid");
}
}
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");
}
}
private PlaybackState playbackState = PlaybackState.Unknown;
private RepeatMode repeatMode;
private boolean shuffled;
private boolean muted;
private double volume;
private int queueCount;
private int queuePosition;
private double duration;
private double currentTime;
private JSONObject track = new JSONObject();
private boolean valid = false;
public TransportModel() {
reset();
}
public boolean canHandle(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());
}
public boolean update(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));
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);
currentTime = message.getDoubleOption(Key.PLAYING_CURRENT_TIME);
track = message.getJsonObjectOption(Key.PLAYING_TRACK, new JSONObject());
valid = true;
return true;
}
public void reset() {
playbackState = PlaybackState.Unknown;
repeatMode = RepeatMode.None;
shuffled = muted = false;
volume = 0.0f;
queueCount = queuePosition = 0;
duration = currentTime = 0.0f;
track = new JSONObject();
valid = false;
}
public boolean isValid() {
return valid;
}
public PlaybackState getPlaybackState() {
return playbackState;
}
public RepeatMode getRepeatMode() {
return repeatMode;
}
public boolean isShuffled() {
return shuffled;
}
public boolean isMuted() {
return muted;
}
public double getVolume() {
return volume;
}
public int getQueueCount() {
return queueCount;
}
public int getQueuePosition() {
return queuePosition;
}
public double getDuration() {
return duration;
}
public double getCurrentTime() {
return currentTime;
}
public long getTrackValueLong(final String key) {
return getTrackValueLong(key, -1);
}
public long getTrackValueLong(final String key, long defaultValue) {
if (track.has(key)) {
return track.optLong(key, defaultValue);
}
return defaultValue;
}
public String getTrackValueString(final String key) {
return getTrackValueString(key, "-");
}
public String getTrackValueString(final String key, final String defaultValue) {
if (track.has(key)) {
return track.optString(key, defaultValue);
}
return defaultValue;
}
}

View File

@ -0,0 +1,190 @@
package io.casey.musikcube.remote.playback;
import android.content.Context;
import android.net.Uri;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
import com.google.android.exoplayer2.extractor.ExtractorsFactory;
import com.google.android.exoplayer2.source.ExtractorMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.TrackSelection;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.trackselection.TrackSelector;
import com.google.android.exoplayer2.upstream.BandwidthMeter;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.util.Util;
import io.casey.musikcube.remote.Application;
public class ExoPlayerWrapper extends PlayerWrapper {
private BandwidthMeter bandwidth;
private DataSource.Factory datasources;
private ExtractorsFactory extractors;
private MediaSource source;
private SimpleExoPlayer player;
private boolean prefetch;
public ExoPlayerWrapper() {
final Context c = Application.getInstance();
this.bandwidth = new DefaultBandwidthMeter();
final TrackSelection.Factory trackFactory = new AdaptiveTrackSelection.Factory(bandwidth);
final TrackSelector trackSelector = new DefaultTrackSelector(trackFactory);
this.player = ExoPlayerFactory.newSimpleInstance(c, trackSelector);
this.datasources = new DefaultDataSourceFactory(c, Util.getUserAgent(c, "musikdroid"));
this.extractors = new DefaultExtractorsFactory();
this.player.addListener(eventListener);
}
@Override
public void play(String uri) {
this.source = new ExtractorMediaSource(Uri.parse(uri), datasources, extractors, null, null);
this.player.setPlayWhenReady(true);
this.player.prepare(this.source);
addActivePlayer(this);
setState(State.Preparing);
}
@Override
public void prefetch(String uri) {
this.prefetch = true;
this.source = new ExtractorMediaSource(Uri.parse(uri), datasources, extractors, null, null);
this.player.setPlayWhenReady(false);
this.player.prepare(this.source);
addActivePlayer(this);
setState(State.Preparing);
}
@Override
public void pause() {
this.prefetch = true;
if (this.getState() == State.Playing) {
this.player.setPlayWhenReady(false);
setState(State.Paused);
}
}
@Override
public void resume() {
if (this.getState() == State.Paused || this.getState() == State.Prepared) {
this.player.setPlayWhenReady(true);
setState(State.Playing);
}
this.prefetch = false;
}
@Override
public void setPosition(int millis) {
if (this.player.getPlaybackState() != ExoPlayer.STATE_IDLE) {
this.player.seekTo(millis);
}
}
@Override
public int getPosition() {
return (int) this.player.getCurrentPosition();
}
@Override
public int getDuration() {
return (int) this.player.getDuration();
}
@Override
public void updateVolume() {
this.player.setVolume(getGlobalVolume());
}
@Override
public void setNextMediaPlayer(PlayerWrapper wrapper) {
}
@Override
public void dispose() {
if (getState() != State.Disposed) {
removeActivePlayer(this);
setState(State.Killing);
this.player.stop();
this.player.release();
setState(State.Disposed);
}
}
@Override
public void setOnStateChangedListener(OnStateChangedListener listener) {
super.setOnStateChangedListener(listener);
}
private ExoPlayer.EventListener eventListener = new ExoPlayer.EventListener() {
@Override
public void onTimelineChanged(Timeline timeline, Object manifest) {
}
@Override
public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
}
@Override
public void onLoadingChanged(boolean isLoading) {
}
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
if (playbackState == ExoPlayer.STATE_READY) {
setState(State.Prepared);
player.setVolume(getGlobalVolume());
if (!prefetch) {
player.setPlayWhenReady(true);
setState(State.Playing);
}
else {
setState(State.Paused);
}
}
else if (playbackState == ExoPlayer.STATE_ENDED) {
setState(State.Finished);
dispose();
}
}
@Override
public void onPlayerError(ExoPlaybackException error) {
switch (getState()) {
case Preparing:
case Prepared:
case Playing:
case Paused:
setState(State.Error);
dispose();
}
}
@Override
public void onPositionDiscontinuity() {
}
@Override
public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
}
};
}

View File

@ -0,0 +1,24 @@
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,209 @@
package io.casey.musikcube.remote.playback;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.PowerManager;
import android.util.Log;
import java.io.IOException;
import io.casey.musikcube.remote.Application;
import io.casey.musikcube.remote.util.Preconditions;
public class MediaPlayerWrapper extends PlayerWrapper {
private static final String TAG = "MediaPlayerWrapper";
private MediaPlayer player = new MediaPlayer();
private int seekTo;
private boolean prefetching;
@Override
public void play(final String uri) {
Preconditions.throwIfNotOnMainThread();
try {
setState(State.Preparing);
player.setDataSource(Application.getInstance(), Uri.parse(uri));
player.setAudioStreamType(AudioManager.STREAM_MUSIC);
player.setOnPreparedListener(onPrepared);
player.setOnErrorListener(onError);
player.setOnCompletionListener(onCompleted);
player.setWakeMode(Application.getInstance(), PowerManager.PARTIAL_WAKE_LOCK);
player.prepareAsync();
}
catch (IOException e) {
Log.e(TAG, "setDataSource failed: " + e.toString());
}
}
@Override
public void prefetch(final String uri) {
Preconditions.throwIfNotOnMainThread();
this.prefetching = true;
play(uri);
}
@Override
public void pause() {
Preconditions.throwIfNotOnMainThread();
if (isPreparedOrPlaying()) {
player.pause();
setState(State.Paused);
}
}
@Override
public void setPosition(int millis) {
Preconditions.throwIfNotOnMainThread();
if (isPreparedOrPlaying()) {
this.player.seekTo(millis);
this.seekTo = 0;
}
else {
this.seekTo = millis;
}
}
@Override
public int getPosition() {
Preconditions.throwIfNotOnMainThread();
if (isPreparedOrPlaying()) {
return this.player.getCurrentPosition();
}
return 0;
}
@Override
public int getDuration() {
Preconditions.throwIfNotOnMainThread();
if (isPreparedOrPlaying()) {
return this.player.getDuration();
}
return 0;
}
@Override
public void resume() {
Preconditions.throwIfNotOnMainThread();
final State state = getState();
if (state == State.Prepared || state == State.Paused) {
player.start();
setState(State.Playing);
}
else {
prefetching = false;
}
}
@Override
public void setNextMediaPlayer(final PlayerWrapper wrapper) {
Preconditions.throwIfNotOnMainThread();
if (isPreparedOrPlaying()) {
try {
this.player.setNextMediaPlayer(wrapper != null ? ((MediaPlayerWrapper) wrapper).player : null);
}
catch (IllegalStateException ex) {
Log.d(TAG, "invalid state for setNextMediaPlayer");
}
}
}
@Override
public void updateVolume() {
Preconditions.throwIfNotOnMainThread();
final State state = getState();
if (state != State.Preparing && state != State.Disposed) {
final float volume = getGlobalVolume();
player.setVolume(volume, volume);
}
}
private boolean isPreparedOrPlaying() {
final State state = getState();
return state == State.Playing || state == State.Prepared;
}
public void dispose() {
Preconditions.throwIfNotOnMainThread();
removeActivePlayer(this);
if (getState() != State.Preparing) {
try {
this.player.setNextMediaPlayer(null);
}
catch (IllegalStateException ex) {
Log.d(TAG, "failed to setNextMediaPlayer(null)");
}
try {
this.player.stop();
}
catch (IllegalStateException ex) {
Log.d(TAG, "failed to stop()");
}
try {
this.player.reset();
}
catch (IllegalStateException ex) {
Log.d(TAG, "failed to reset()");
}
this.player.release();
setOnStateChangedListener(null);
setState(State.Disposed);
}
else {
setState(State.Killing);
}
}
private MediaPlayer.OnPreparedListener onPrepared = (mediaPlayer) -> {
if (this.getState() == State.Killing) {
dispose();
}
else {
final float volume = getGlobalVolume();
player.setVolume(volume, volume);
addActivePlayer(this);
if (prefetching) {
setState(State.Prepared);
}
else {
this.player.start();
if (this.seekTo != 0) {
setPosition(this.seekTo);
}
setState(State.Playing);
}
this.prefetching = false;
}
};
private MediaPlayer.OnErrorListener onError = (player, what, extra) -> {
setState(State.Error);
dispose();
return true;
};
private MediaPlayer.OnCompletionListener onCompleted = (mp) -> {
setState(State.Finished);
dispose();
};
}

View File

@ -0,0 +1,25 @@
package io.casey.musikcube.remote.playback;
public class Metadata {
public interface Track {
String ID = "id";
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 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,60 @@
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();
int getQueueCount();
int getQueuePosition();
double getVolume();
double getDuration();
double getCurrentTime();
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,38 @@
package io.casey.musikcube.remote.playback;
import android.content.Context;
import android.content.SharedPreferences;
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("streaming_playback", true)) {
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", Context.MODE_PRIVATE);
streaming = new StreamingPlaybackService(context);
remote = new RemotePlaybackService(context);
}
}
}

View File

@ -0,0 +1,34 @@
package io.casey.musikcube.remote.playback;
public enum PlaybackState {
Unknown("unknown"),
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)) {
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,109 @@
package io.casey.musikcube.remote.playback;
import java.util.HashSet;
import java.util.Set;
import io.casey.musikcube.remote.util.Preconditions;
public abstract class PlayerWrapper {
private static final String TAG = "MediaPlayerWrapper";
public enum State {
Stopped,
Preparing,
Prepared,
Playing,
Paused,
Error,
Finished,
Killing,
Disposed
}
public interface OnStateChangedListener {
void onStateChanged(PlayerWrapper mpw, State state);
}
private static Set<PlayerWrapper> activePlayers = new HashSet<>();
private static float globalVolume = 1.0f;
private static boolean globalMuted = false;
public static void setGlobalVolume(final float volume) {
Preconditions.throwIfNotOnMainThread();
globalVolume = volume;
for (final PlayerWrapper w : activePlayers) {
w.updateVolume();
}
}
public static float getGlobalVolume() {
Preconditions.throwIfNotOnMainThread();
return globalMuted ? 0 : globalVolume;
}
public static void setGlobalMute(final boolean muted) {
Preconditions.throwIfNotOnMainThread();
if (PlayerWrapper.globalMuted != muted) {
PlayerWrapper.globalMuted = muted;
for (final PlayerWrapper w : activePlayers) {
w.updateVolume();
}
}
}
public static PlayerWrapper newInstance() {
//return new MediaPlayerWrapper();
return new ExoPlayerWrapper();
}
protected static void addActivePlayer(final PlayerWrapper player) {
Preconditions.throwIfNotOnMainThread();
activePlayers.add(player);
}
protected static void removeActivePlayer(final PlayerWrapper player) {
Preconditions.throwIfNotOnMainThread();
activePlayers.remove(player);
}
private OnStateChangedListener listener;
private State state = State.Stopped;
public abstract void play(final String uri);
public abstract void prefetch(final String uri);
public abstract void pause();
public abstract void resume();
public abstract void setPosition(int millis);
public abstract int getPosition();
public abstract int getDuration();
public abstract void updateVolume();
public abstract void setNextMediaPlayer(final PlayerWrapper wrapper);
public abstract void dispose();
public void setOnStateChangedListener(OnStateChangedListener listener) {
Preconditions.throwIfNotOnMainThread();
this.listener = listener;
if (listener != null) {
this.listener.onStateChanged(this, state);
}
}
public final State getState() {
return this.state;
}
protected void setState(final PlayerWrapper.State state) {
if (this.state != state) {
this.state = state;
if (listener != null) {
this.listener.onStateChanged(this, state);
}
}
}
}

View File

@ -0,0 +1,356 @@
package io.casey.musikcube.remote.playback;
import android.content.Context;
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 WebSocketService wss;
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";
}
private PlaybackState playbackState = PlaybackState.Unknown;
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 double currentTime;
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 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);
}
}
}
@Override
public synchronized void disconnect(EventListener listener) {
if (listener != null) {
listeners.remove(listener);
if (listeners.size() == 0) {
wss.removeClient(client);
}
}
}
@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;
}
@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.Unknown;
repeatMode = RepeatMode.None;
shuffled = muted = false;
volume = 0.0f;
queueCount = queuePosition = 0;
duration = currentTime = 0.0f;
track = new JSONObject();
}
private boolean canHandle(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 update(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));
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);
currentTime = message.getDoubleOption(Key.PLAYING_CURRENT_TIME);
track = message.getJsonObjectOption(Key.PLAYING_TRACK, new JSONObject());
notifyStateUpdated();
return true;
}
private synchronized void notifyStateUpdated() {
for (final EventListener listener : listeners) {
listener.onStateUpdated();
}
}
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();
}
};
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 (canHandle(message)) {
update(message);
}
}
@Override
public void onInvalidPassword() {
}
};
}

View File

@ -0,0 +1,30 @@
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,894 @@
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 org.json.JSONArray;
import org.json.JSONObject;
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.ui.model.TrackListSlidingWindow;
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.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 = 2000;
private static final int MAX_TRACK_METADATA_CACHE_SIZE = 50;
private static final int PRECACHE_METADATA_SIZE = 10;
private static final int PAUSED_SERVICE_SHUTDOWN_DELAY_MS = 1000 * 60; /* 1 minute */
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 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 stopPlayback() {
reset(currentPlayer);
reset(nextPlayer);
nextPlayerScheduled = false;
}
public void stopPlaybackAndReset() {
stopPlayback();
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.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", Context.MODE_PRIVATE);
this.audioManager = (AudioManager) Application.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.stopPlayback();
loadQueueAndPlay(this.params, index);
}
}
@Override
public void pauseOrResume() {
if (context.currentPlayer != null) {
if (state == PlaybackState.Playing) {
pause();
}
else {
resume();
}
}
}
@Override
public void pause() {
if (state != PlaybackState.Paused) {
schedulePausedShutdown();
killAudioFocus();
context.currentPlayer.pause();
setState(PlaybackState.Paused);
}
}
@Override
public void resume() {
if (requestAudioFocus()) {
cancelScheduledPausedShutdown();
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()) {
cancelScheduledPausedShutdown();
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()) {
cancelScheduledPausedShutdown();
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 int getQueueCount() {
return context.queueCount;
}
@Override
public int getQueuePosition() {
return context.currentIndex;
}
@Override
public double getVolume() {
if (prefs.getBoolean("software_volume", false)) {
return PlayerWrapper.getGlobalVolume();
}
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.setGlobalMute(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 TrackListSlidingWindow.QueryFactory getPlaylistQueryFactory() {
return this.queryFactory;
}
private float getVolumeStep() {
if (prefs.getBoolean("software_volume", false)) {
return 0.1f;
}
return 1.0f / getMaxSystemVolume();
}
private void adjustVolume(float delta) {
if (muted) {
toggleMute();
}
final boolean softwareVolume = prefs.getBoolean("software_volume", false);
float current = softwareVolume ? PlayerWrapper.getGlobalVolume() : getSystemVolume();
current += delta;
if (current > 1.0) current = 1.0f;
if (current < 0.0) current = 0.0f;
if (softwareVolume) {
PlayerWrapper.setGlobalVolume(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();
cancelScheduledPausedShutdown();
precacheTrackMetadata(context.currentIndex, PRECACHE_METADATA_SIZE);
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 long trackId = track.optLong("id", -1);
if (trackId != -1) {
return String.format(
Locale.ENGLISH,
"http://%s:%d/audio/id/%d",
prefs.getString("address", "192.168.1.100"),
prefs.getInt("http_port", 7906),
trackId);
}
}
return null;
}
private void playCurrentTrack() {
this.context.stopPlayback();
final String uri = getUri(this.context.currentMetadata);
if (uri != null) {
this.context.currentPlayer = PlayerWrapper.newInstance();
this.context.currentPlayer.setOnStateChangedListener(onCurrentPlayerStateChanged);
this.context.currentPlayer.play(uri);
setState(PlaybackState.Buffering);
}
}
private void onPlayQueueLoaded() {
if (this.state == PlaybackState.Buffering) {
playCurrentTrack();
}
}
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.newInstance();
this.context.nextPlayer.setOnStateChangedListener(onNextPlayerStateChanged);
this.context.nextPlayer.prefetch(uri);
}
}
}
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)
.doOnNext(track -> {
if (params == this.params && context.currentIndex == currentIndex) {
if (context.nextMetadata == null) {
context.nextIndex = nextIndex;
context.nextMetadata = track;
prefetchNextTrackAudio();
}
}
})
.subscribe();
}
}
}
private void loadQueueAndPlay(final QueueParams params, int startIndex) {
setState(PlaybackState.Buffering);
cancelScheduledPausedShutdown();
SystemService.wakeup();
this.context.stopPlayback();
final PlaybackContext context = new PlaybackContext();
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)
.doOnNext(track -> {
if (context.currentMetadata == null) {
context.currentMetadata = track;
}
else {
context.nextMetadata = track;
}
})
.doOnComplete(() -> {
if (StreamingPlaybackService.this.params == params) {
StreamingPlaybackService.this.context = context;
onPlayQueueLoaded();
}
})
.subscribe();
}
private void cancelScheduledPausedShutdown() {
SystemService.wakeup();
handler.removeCallbacks(pauseServiceShutdownRunnable);
}
private void schedulePausedShutdown() {
handler.postDelayed(pauseServiceShutdownRunnable, PAUSED_SERVICE_SHUTDOWN_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())
.doOnNext(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));
}
}
})
.subscribe();
}
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 null;
}
@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;
}
};
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 pauseServiceShutdownRunnable = () -> SystemService.shutdown();
private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener
= new AudioManager.OnAudioFocusChangeListener() {
@Override
public void onAudioFocusChange(int 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:
break;
case AudioManager.AUDIOFOCUS_LOSS:
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
killAudioFocus();
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,375 @@
package io.casey.musikcube.remote.playback;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
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 io.casey.musikcube.remote.Application;
import io.casey.musikcube.remote.MainActivity;
import io.casey.musikcube.remote.R;
import io.casey.musikcube.remote.util.Strings;
/* 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 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";
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 StreamingPlaybackService playback;
private PowerManager.WakeLock wakeLock;
private PowerManager powerManager;
private MediaSessionCompat mediaSession;
public static void wakeup() {
final Context c = Application.getInstance();
c.startService(new Intent(c, SystemService.class).setAction(ACTION_WAKE_UP));
}
public static void shutdown() {
final Context c = Application.getInstance();
c.startService(new Intent(c, SystemService.class).setAction(ACTION_SHUT_DOWN));
final Exception ex = new Exception();
ex.fillInStackTrace();
ex.printStackTrace();
}
@Override
public void onCreate() {
super.onCreate();
powerManager = (PowerManager) getSystemService(POWER_SERVICE);
}
@Override
public void onDestroy() {
super.onDestroy();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
final String action = intent.getAction();
if (ACTION_WAKE_UP.equals(action)) {
Log.d(TAG, "SystemService WAKE_UP");
if (playback == null) {
playback = PlaybackServiceFactory.streaming(this);
playback.connect(listener);
initMediaSession();
}
if (wakeLock == null) {
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "StreamingPlaybackService");
wakeLock.setReferenceCounted(false);
wakeLock.acquire();
}
}
else if (ACTION_SHUT_DOWN.equals(action)) {
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();
}
else if (handlePlaybackAction(action)) {
return super.onStartCommand(intent, flags, startId);
}
}
return super.onStartCommand(intent, flags, startId);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
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 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, duration);
updateNotification(title, artist, album, mediaSessionState);
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder()
.setState(mediaSessionState, 0, 0)
.setActions(MEDIA_SESSION_ACTIONS)
.build());
}
private void updateMetadata(final String title, final String artist, final String album, int duration) {
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,
// BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher))
.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.pause();
SystemService.shutdown();
return true;
}
}
return false;
}
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:
return false;
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();
};
}

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.activity;
import android.content.Context;
import android.content.Intent;
@ -14,7 +14,19 @@ import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import static io.casey.musikcube.remote.Messages.Key;
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.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";
@ -102,6 +114,11 @@ public class AlbumBrowseActivity extends WebSocketActivityBase implements Filter
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) {
@ -156,7 +173,7 @@ public class AlbumBrowseActivity extends WebSocketActivityBase implements Filter
private View.OnClickListener onItemClickListener = (View view) -> {
final JSONObject album = (JSONObject) view.getTag();
final long id = album.optLong(Key.ID);
final String title = album.optString(Key.TITLE, "");
final String title = album.optString(Metadata.Album.TITLE, "");
final Intent intent = TrackListActivity.getStartIntent(
AlbumBrowseActivity.this, Messages.Category.ALBUM, id, title);
@ -175,7 +192,7 @@ public class AlbumBrowseActivity extends WebSocketActivityBase implements Filter
}
void bind(JSONObject entry) {
long playingId = transport.getModel().getTrackValueLong(Key.ALBUM_ID, -1);
long playingId = transport.getPlaybackService().getTrackLong(Metadata.Track.ALBUM_ID, -1);
long entryId = entry.optLong(Key.ID);
int titleColor = R.color.theme_foreground;
@ -186,10 +203,10 @@ public class AlbumBrowseActivity extends WebSocketActivityBase implements Filter
subtitleColor = R.color.theme_yellow;
}
title.setText(entry.optString(Key.TITLE, "-"));
title.setText(entry.optString(Metadata.Album.TITLE, "-"));
title.setTextColor(getResources().getColor(titleColor));
subtitle.setText(entry.optString(Key.ALBUM_ARTIST, "-"));
subtitle.setText(entry.optString(Metadata.Album.ALBUM_ARTIST, "-"));
subtitle.setTextColor(getResources().getColor(subtitleColor));
itemView.setTag(entry);

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.activity;
import android.content.Context;
import android.content.Intent;
@ -17,6 +17,18 @@ 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.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";
@ -30,11 +42,11 @@ public class CategoryBrowseActivity extends WebSocketActivityBase implements Fil
private static final Map<String, Integer> CATEGORY_NAME_TO_TITLE = new HashMap<>();
static {
CATEGORY_NAME_TO_ID.put(Messages.Category.ALBUM_ARTIST, Messages.Key.ALBUM_ARTIST_ID);
CATEGORY_NAME_TO_ID.put(Messages.Category.GENRE, Messages.Key.GENRE_ID);
CATEGORY_NAME_TO_ID.put(Messages.Category.ARTIST, Messages.Key.ARTIST_ID);
CATEGORY_NAME_TO_ID.put(Messages.Category.ALBUM, Messages.Key.ALBUM_ID);
CATEGORY_NAME_TO_ID.put(Messages.Category.PLAYLISTS, Messages.Key.ALBUM_ID);
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);
@ -114,6 +126,11 @@ public class CategoryBrowseActivity extends WebSocketActivityBase implements Fil
return socketClient;
}
@Override
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
return null;
}
private void requery() {
final SocketMessage request = SocketMessage.Builder
.request(Messages.Request.QueryCategory)
@ -205,7 +222,7 @@ public class CategoryBrowseActivity extends WebSocketActivityBase implements Fil
final String idKey = CATEGORY_NAME_TO_ID.get(category);
if (idKey != null && idKey.length() > 0) {
playingId = transport.getModel().getTrackValueLong(idKey, -1);
playingId = transport.getPlaybackService().getTrackLong(idKey, -1);
}
int titleColor = R.color.theme_foreground;

View File

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

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.activity;
import android.content.Context;
import android.content.Intent;
@ -12,6 +12,15 @@ import android.widget.TextView;
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;
public class PlayQueueActivity extends WebSocketActivityBase {
private static String EXTRA_PLAYING_INDEX = "extra_playing_index";
@ -21,8 +30,8 @@ public class PlayQueueActivity extends WebSocketActivityBase {
}
private WebSocketService wss;
private TrackListScrollCache<JSONObject> tracks;
private TransportModel transportModel = new TransportModel();
private TrackListSlidingWindow<JSONObject> tracks;
private PlaybackService playback;
private Adapter adapter;
@Override
@ -30,6 +39,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
super.onCreate(savedInstanceState);
this.wss = getWebSocketService();
this.playback = getPlaybackService();
setContentView(R.layout.recycler_view_activity);
@ -41,8 +51,11 @@ public class PlayQueueActivity extends WebSocketActivityBase {
Views.setupDefaultRecyclerView(this, recyclerView, adapter);
this.tracks = new TrackListScrollCache<>(
recyclerView, adapter, this.wss, this.queryFactory, (JSONObject obj) -> obj);
this.tracks = new TrackListSlidingWindow<>(
recyclerView,
this.wss,
this.playback.getPlaylistQueryFactory(),
(JSONObject obj) -> obj);
this.tracks.setInitialPosition(
getIntent().getIntExtra(EXTRA_PLAYING_INDEX, -1));
@ -68,63 +81,38 @@ public class PlayQueueActivity extends WebSocketActivityBase {
return webSocketClient;
}
private void updatePlaybackModel(final SocketMessage message) {
transportModel.update(message);
adapter.notifyDataSetChanged();
@Override
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
return this.playbackEvents;
}
private final TrackListScrollCache.QueryFactory queryFactory
= new TrackListScrollCache.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();
}
};
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();
wss.send(SocketMessage
.Builder.request(Messages.Request.PlayAtIndex)
.addOption(Messages.Key.INDEX, index)
.build());
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) {
final SocketMessage overview = SocketMessage.Builder
.request(Messages.Request.GetPlaybackOverview).build();
wss.send(overview, this, (SocketMessage response) -> updatePlaybackModel(response));
tracks.requery();
}
}
@Override
public void onMessageReceived(SocketMessage broadcast) {
if (Messages.Broadcast.PlaybackOverviewChanged.is(broadcast.getName())) {
updatePlaybackModel(broadcast);
}
}
@Override
@ -155,7 +143,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
subtitle.setText("-");
}
else {
long playingId = transportModel.getTrackValueLong(Messages.Key.ID, -1);
long playingId = playback.getTrackLong(Messages.Key.ID, -1);
long entryId = entry.optLong(Messages.Key.ID, -1);
if (entryId != -1 && playingId == entryId) {
@ -163,8 +151,8 @@ public class PlayQueueActivity extends WebSocketActivityBase {
subtitleColor = R.color.theme_yellow;
}
title.setText(entry.optString(Messages.Key.TITLE, "-"));
subtitle.setText(entry.optString(Messages.Key.ALBUM_ARTIST, "-"));
title.setText(entry.optString(Metadata.Track.TITLE, "-"));
subtitle.setText(entry.optString(Metadata.Track.ALBUM_ARTIST, "-"));
}
title.setTextColor(getResources().getColor(titleColor));

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.activity;
import android.content.Context;
import android.content.Intent;
@ -8,14 +8,24 @@ import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.view.MenuItem;
import android.view.View;
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.MediaPlayerWrapper;
import io.casey.musikcube.remote.playback.PlaybackService;
import io.casey.musikcube.remote.playback.PlaybackServiceFactory;
import io.casey.musikcube.remote.ui.util.Views;
import io.casey.musikcube.remote.websocket.WebSocketService;
public class SettingsActivity extends AppCompatActivity {
private EditText addressText, portText, passwordText;
private CheckBox albumArtCheckbox, messageCompressionCheckbox;
private EditText addressText, portText, httpPortText, passwordText;
private CheckBox albumArtCheckbox, messageCompressionCheckbox, softwareVolume;
private Spinner playbackModeSpinner;
private SharedPreferences prefs;
public static Intent getStartIntent(final Context context) {
@ -45,33 +55,69 @@ public class SettingsActivity extends AppCompatActivity {
private void rebindUi() {
Views.setTextAndMoveCursorToEnd(this.addressText, prefs.getString("address", "192.168.1.100"));
Views.setTextAndMoveCursorToEnd(this.portText, String.format(Locale.ENGLISH, "%d", prefs.getInt("port", 7905)));
Views.setTextAndMoveCursorToEnd(this.httpPortText, String.format(Locale.ENGLISH, "%d", prefs.getInt("http_port", 7906)));
Views.setTextAndMoveCursorToEnd(this.passwordText, prefs.getString("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);
this.albumArtCheckbox.setChecked(this.prefs.getBoolean("album_art_enabled", true));
this.messageCompressionCheckbox.setChecked(this.prefs.getBoolean("message_compression_enabled", true));
this.softwareVolume.setChecked(this.prefs.getBoolean("software_volume", false));
Views.enableUpNavigation(this);
}
private boolean isStreamingEnabled() {
return this.prefs.getBoolean("streaming_playback", false);
}
private boolean isStreamingSelected() {
return this.playbackModeSpinner.getSelectedItemPosition() == 1;
}
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.albumArtCheckbox.setChecked(this.prefs.getBoolean("album_art_enabled", true));
this.messageCompressionCheckbox.setChecked(this.prefs.getBoolean("message_compression_enabled", true));
final boolean wasStreaming = isStreamingEnabled();
this.findViewById(R.id.button_connect).setOnClickListener((View v) -> {
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("address", addr)
.putInt("port", (port.length() > 0) ? Integer.valueOf(port) : 0)
.putInt("http_port", (httpPort.length() > 0) ? Integer.valueOf(httpPort) : 0)
.putString("password", password)
.putBoolean("album_art_enabled", albumArtCheckbox.isChecked())
.putBoolean("message_compression_enabled", messageCompressionCheckbox.isChecked())
.putBoolean("streaming_playback", isStreamingSelected())
.putBoolean("software_volume", softwareVolume.isChecked())
.apply();
if (!softwareVolume.isChecked()) {
MediaPlayerWrapper.setGlobalVolume(1.0f);
}
if (wasStreaming && !isStreamingEnabled()) {
PlaybackServiceFactory.streaming(this).stop();
}
WebSocketService.getInstance(this).disconnect();
finish();

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.activity;
import android.content.Context;
import android.content.Intent;
@ -13,7 +13,20 @@ import android.widget.TextView;
import org.json.JSONObject;
import static io.casey.musikcube.remote.TrackListScrollCache.QueryFactory;
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.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.model.TrackListSlidingWindow.QueryFactory;
public class TrackListActivity extends WebSocketActivityBase implements Filterable {
private static String EXTRA_CATEGORY_TYPE = "extra_category_type";
@ -44,7 +57,7 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
return new Intent(context, TrackListActivity.class);
}
private TrackListScrollCache<JSONObject> tracks;
private TrackListSlidingWindow<JSONObject> tracks;
private TransportFragment transport;
private String categoryType;
private long categoryId;
@ -69,8 +82,8 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
Views.setupDefaultRecyclerView(this, recyclerView, adapter);
tracks = new TrackListScrollCache<>(
recyclerView, adapter, getWebSocketService(), queryFactory, (JSONObject track) -> track);
tracks = new TrackListSlidingWindow<>(
recyclerView, getWebSocketService(), queryFactory, (JSONObject track) -> track);
transport = Views.addTransportFragment(this,
(TransportFragment fragment) -> adapter.notifyDataSetChanged());
@ -101,6 +114,11 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
return socketServiceClient;
}
@Override
protected PlaybackService.EventListener getPlaybackServiceEventListener() {
return null;
}
@Override
public void setFilter(String filter) {
this.lastFilter = filter;
@ -136,29 +154,16 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
private View.OnClickListener onItemClickListener = (View view) -> {
int index = (Integer) view.getTag();
SocketMessage request;
if (isValidCategory(categoryType, categoryId)) {
request = SocketMessage.Builder
.request(Messages.Request.PlayTracksByCategory)
.addOption(Messages.Key.CATEGORY, categoryType)
.addOption(Messages.Key.ID, categoryId)
.addOption(Messages.Key.INDEX, index)
.addOption(Messages.Key.FILTER, lastFilter)
.build();
getPlaybackService().play(categoryType, categoryId, index, lastFilter);
}
else {
request = SocketMessage.Builder
.request(Messages.Request.PlayAllTracks)
.addOption(Messages.Key.INDEX, index)
.addOption(Messages.Key.FILTER, lastFilter)
.build();
getPlaybackService().playAll(index, lastFilter);
}
getWebSocketService().send(request, socketServiceClient, (SocketMessage response) -> {
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
finish();
});
setResult(Navigation.ResponseCode.PLAYBACK_STARTED);
finish();
};
private class ViewHolder extends RecyclerView.ViewHolder {
@ -180,7 +185,7 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
int subtitleColor = R.color.theme_disabled_foreground;
if (entry != null) {
long playingId = transport.getModel().getTrackValueLong(Messages.Key.ID, -1);
long playingId = transport.getPlaybackService().getTrackLong(Messages.Key.ID, -1);
long entryId = entry.optLong(Messages.Key.ID, -1);
if (entryId != -1 && playingId == entryId) {
@ -188,8 +193,8 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
subtitleColor = R.color.theme_yellow;
}
title.setText(entry.optString(Messages.Key.TITLE, "-"));
subtitle.setText(entry.optString(Messages.Key.ALBUM_ARTIST, "-"));
title.setText(entry.optString(Metadata.Track.TITLE, "-"));
subtitle.setText(entry.optString(Metadata.Track.ALBUM_ARTIST, "-"));
}
else {
title.setText("-");

View File

@ -1,5 +1,8 @@
package io.casey.musikcube.remote;
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;
@ -10,17 +13,26 @@ 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.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", Context.MODE_PRIVATE);
}
@Override
@ -28,6 +40,7 @@ public abstract class WebSocketActivityBase extends AppCompatActivity implements
super.onPause();
this.runnerDelegate.onPause();
this.wss.removeClient(getWebSocketServiceClient());
this.playback.disconnect(getPlaybackServiceEventListener());
this.paused = true;
}
@ -35,6 +48,8 @@ public abstract class WebSocketActivityBase extends AppCompatActivity implements
protected void onResume() {
super.onResume();
this.runnerDelegate.onResume();
this.playback = PlaybackServiceFactory.instance(this);
this.playback.connect(getPlaybackServiceEventListener());
this.wss.addClient(getWebSocketServiceClient());
this.paused = false;
}
@ -53,20 +68,18 @@ public abstract class WebSocketActivityBase extends AppCompatActivity implements
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
wss.send(SocketMessage.Builder
.request(Messages.Request.SetVolume)
.addOption(Messages.Key.RELATIVE, Messages.Value.DOWN)
.build());
return true;
}
else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
wss.send(SocketMessage.Builder
.request(Messages.Request.SetVolume)
.addOption(Messages.Key.RELATIVE, Messages.Value.UP)
.build());
boolean streaming = prefs.getBoolean("streaming_playback", false);
return true;
/* 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);
@ -104,5 +117,10 @@ public abstract class WebSocketActivityBase extends AppCompatActivity implements
return this.wss;
}
protected final PlaybackService getPlaybackService() {
return this.playback;
}
protected abstract WebSocketService.Client getWebSocketServiceClient();
protected abstract PlaybackService.EventListener getPlaybackServiceEventListener();
}

View File

@ -1,10 +1,13 @@
package io.casey.musikcube.remote;
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";

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.fragment;
import android.content.Intent;
import android.os.Bundle;
@ -9,6 +9,14 @@ 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";
@ -16,10 +24,9 @@ public class TransportFragment extends Fragment {
return new TransportFragment();
}
private WebSocketService wss;
private View rootView;
private TextView title, playPause;
private TransportModel transportModel = new TransportModel();
private PlaybackService playback;
private OnModelChangedListener modelChangedListener;
public interface OnModelChangedListener {
@ -41,24 +48,23 @@ public class TransportFragment extends Fragment {
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.wss = WebSocketService.getInstance(getActivity());
this.playback = PlaybackServiceFactory.instance(getActivity());
}
@Override
public void onPause() {
super.onPause();
this.wss.removeClient(socketClient);
this.playback.disconnect(this.playbackListener);
}
@Override
public void onResume() {
super.onResume();
this.wss.addClient(socketClient);
this.playback.connect(this.playbackListener);
}
public TransportModel getModel() {
return transportModel;
public PlaybackService getPlaybackService() {
return playback;
}
public void setModelChangedListener(OnModelChangedListener modelChangedListener) {
@ -69,9 +75,9 @@ public class TransportFragment extends Fragment {
this.title = (TextView) this.rootView.findViewById(R.id.track_title);
this.title.setOnClickListener((View view) -> {
if (transportModel.getPlaybackState() != TransportModel.PlaybackState.Stopped) {
if (playback.getPlaybackState() != PlaybackState.Stopped) {
final Intent intent = PlayQueueActivity
.getStartIntent(getActivity(), transportModel.getQueuePosition())
.getStartIntent(getActivity(), playback.getQueuePosition())
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(intent);
@ -83,70 +89,46 @@ public class TransportFragment extends Fragment {
return true;
});
this.rootView.findViewById(R.id.button_prev).setOnClickListener((View view) -> {
wss.send(SocketMessage.Builder.request(
Messages.Request.Previous).build());
});
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 (transportModel.getPlaybackState() == TransportModel.PlaybackState.Stopped) {
wss.send(SocketMessage.Builder.request(
Messages.Request.PlayAllTracks).build());
if (playback.getPlaybackState() == PlaybackState.Stopped) {
playback.playAll();
}
else {
wss.send(SocketMessage.Builder.request(
Messages.Request.PauseOrResume).build());
playback.pauseOrResume();
}
});
this.rootView.findViewById(R.id.button_next).setOnClickListener((View view) -> {
wss.send(SocketMessage.Builder.request(
Messages.Request.Next).build());
});
this.rootView.findViewById(R.id.button_next).setOnClickListener((View view) -> playback.next());
}
private void rebindUi() {
TransportModel.PlaybackState state = transportModel.getPlaybackState();
PlaybackState state = playback.getPlaybackState();
final boolean playing = (state == TransportModel.PlaybackState.Playing);
final boolean playing = (state == PlaybackState.Playing);
this.playPause.setText(playing ? R.string.button_pause : R.string.button_play);
if (state == TransportModel.PlaybackState.Stopped) {
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));
title.setText(transportModel.getTrackValueString(TransportModel.Key.TITLE, "(unknown title)"));
title.setText(playback.getTrackString(Metadata.Track.TITLE, "(unknown title)"));
}
}
private WebSocketService.Client socketClient = new WebSocketService.Client() {
private PlaybackService.EventListener playbackListener = new PlaybackService.EventListener() {
@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());
public void onStateUpdated() {
rebindUi();
if (modelChangedListener != null) {
modelChangedListener.onChanged(TransportFragment.this);
}
}
@Override
public void onMessageReceived(SocketMessage message) {
if (transportModel.canHandle(message)) {
if (transportModel.update(message)) {
rebindUi();
if (modelChangedListener != null) {
modelChangedListener.onChanged(TransportFragment.this);
}
}
}
}
@Override
public void onInvalidPassword() {
}
};
}

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.model;
import android.util.LruCache;
@ -12,6 +12,7 @@ import java.net.URLEncoder;
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;

View File

@ -0,0 +1,266 @@
package io.casey.musikcube.remote.ui.model;
import android.support.v7.widget.RecyclerView;
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 WebSocketService wss;
private Mapper<TrackType> mapper;
private QueryFactory queryFactory;
private int scrollState = RecyclerView.SCROLL_STATE_IDLE;
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 interface QueryFactory {
SocketMessage getRequeryMessage();
SocketMessage getPageAroundMessage(int offset, int limit);
}
public TrackListSlidingWindow(RecyclerView recyclerView,
WebSocketService wss,
QueryFactory queryFactory,
Mapper<TrackType> mapper) {
this.recyclerView = recyclerView;
this.wss = wss;
this.queryFactory = queryFactory;
this.mapper = mapper;
}
public TrackListSlidingWindow(WebSocketService wss,
QueryFactory queryFactory,
Mapper<TrackType> mapper) {
this.recyclerView = null;
this.wss = wss;
this.queryFactory = queryFactory;
this.mapper = mapper;
}
public void setQueryFactory(final QueryFactory factory) {
this.queryFactory = factory;
requery();
}
public void requery() {
if (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);
}
}
public void resume() {
if (this.recyclerView != null) {
this.recyclerView.addOnScrollListener(scrollListener);
}
this.wss.addClient(this.client);
connected = true;
}
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) {
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 RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
scrollState = newState;
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
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

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.util;
import android.app.SearchManager;
import android.app.SearchableInfo;
@ -17,6 +17,11 @@ import android.view.ViewPropertyAnimator;
import android.widget.CheckBox;
import android.widget.EditText;
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";

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.ui.view;
import android.content.Context;
import android.os.Handler;

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.util;
import android.os.Handler;
import android.os.Looper;

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.util;
import android.app.Activity;

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.util;
import android.os.Looper;

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.util;
public class Strings {
public static boolean empty(final String s) {

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.websocket;
public class Messages {
public enum Request {
@ -83,15 +83,6 @@ public class Messages {
String DATA = "data";
String ID = "id";
String IDS = "ids";
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 GENRE_ID = "visual_genre_id";
String ARTIST = "artist";
String ARTIST_ID = "visual_artist_id";
String COUNT = "count";
String COUNT_ONLY = "count_only";
String OFFSET = "offset";

View File

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.websocket;
import android.util.Log;
@ -209,6 +209,20 @@ public class SocketMessage {
}
}
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();
@ -267,6 +281,11 @@ public class SocketMessage {
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

@ -1,4 +1,4 @@
package io.casey.musikcube.remote;
package io.casey.musikcube.remote.websocket;
import android.content.BroadcastReceiver;
import android.content.Context;
@ -10,6 +10,7 @@ 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;
@ -25,15 +26,23 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
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 = 5000;
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;
@ -171,12 +180,12 @@ public class WebSocketService {
if (!this.clients.contains(client)) {
this.clients.add(client);
if (this.clients.size() == 1) {
if (this.clients.size() >= 0 && state == State.Disconnected) {
registerReceiverAndScheduleFailsafe();
reconnect();
handler.removeCallbacks(autoDisconnectRunnable);
}
handler.removeCallbacks(autoDisconnectRunnable);
client.onStateChanged(getState(), getState());
}
}
@ -274,6 +283,47 @@ public class WebSocketService {
return -1;
}
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();
if (socket != null) {
/* 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.isOpen()) {
disconnect(true);
throw new Exception("socket disconnected");
}
else {
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.callback = (SocketMessage message) -> {
emitter.onNext(message);
emitter.onComplete();
};
messageCallbacks.put(message.getId(), mrd);
socket.sendText(message.toString());
}
}
}
catch (Exception ex) {
emitter.onError(ex);
}
}
});
}
public boolean hasValidConnection() {
final String addr = prefs.getString("address", "");
final int port = prefs.getInt("port", -1);
@ -365,6 +415,8 @@ public class WebSocketService {
private void setState(State state) {
Preconditions.throwIfNotOnMainThread();
Log.d(TAG, "state = " + state);
if (this.state != state) {
State old = this.state;
this.state = state;

View File

@ -266,7 +266,7 @@
android:layout_marginTop="2dp"
android:orientation="horizontal">
<io.casey.musikcube.remote.LongPressTextView
<io.casey.musikcube.remote.ui.view.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_down"
@ -278,7 +278,7 @@
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
<io.casey.musikcube.remote.ui.view.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_back"
@ -290,7 +290,7 @@
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
<io.casey.musikcube.remote.ui.view.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_seek_forward"
@ -302,7 +302,7 @@
android:layout_width="2dp"
android:layout_height="0dp" />
<io.casey.musikcube.remote.LongPressTextView
<io.casey.musikcube.remote.ui.view.LongPressTextView
style="@style/PlaybackButton"
android:layout_weight="1.0"
android:id="@+id/button_vol_up"

View File

@ -56,6 +56,22 @@
</android.support.design.widget.TextInputLayout>
<android.support.design.widget.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:layout_marginLeft="24dp">
<EditText
android:id="@+id/http_port"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:hint="@string/edit_connection_http_port"
android:inputType="number" />
</android.support.design.widget.TextInputLayout>
<android.support.design.widget.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -76,11 +92,37 @@
android:layout_width="0dp"
android:layout_height="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginRight="8dp"
android:text="@string/settings_playback_mode"/>
<Spinner
android:id="@+id/playback_mode_spinner"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginLeft="24dp"/>
<android.support.v4.widget.Space
android:layout_width="0dp"
android:layout_height="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:text="@string/settings_general"/>
<CheckBox
android:id="@+id/album_art_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/theme_foreground"
android:layout_marginLeft="24dp"
android:text="@string/settings_enable_album_art"/>
<CheckBox
@ -88,8 +130,17 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/theme_foreground"
android:layout_marginLeft="24dp"
android:text="@string/settings_enable_message_compression"/>
<CheckBox
android:id="@+id/software_volume"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/theme_foreground"
android:layout_marginLeft="24dp"
android:text="@string/settings_enable_software_volume"/>
</LinearLayout>
</ScrollView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="streaming_mode_array">
<item>remote</item>
<item>streaming</item>
</string-array>
</resources>

View File

@ -23,6 +23,7 @@
<string name="button_vol_up">vol +</string>
<string name="button_vol_down">vol -</string>
<string name="button_shuffle">shuffle</string>
<string name="button_random">random</string>
<string name="button_mute">mute</string>
<string name="button_repeat_off">repeat off</string>
<string name="button_repeat_list">repeat list</string>
@ -38,7 +39,8 @@
<string name="status_volume">volume %1d%%</string>
<string name="edit_connection_info">connection info:</string>
<string name="edit_connection_hostname">ip address or hostname</string>
<string name="edit_connection_port">port (default 7905)</string>
<string name="edit_connection_port">main server port (default 7905)</string>
<string name="edit_connection_http_port">audio streaming port (default 7906)</string>
<string name="edit_connection_password">password (default empty)</string>
<string name="transport_not_playing">not playing</string>
<string name="search_hint">search</string>
@ -46,8 +48,12 @@
<string name="menu_genres">genres</string>
<string name="menu_playlists">playlists</string>
<string name="unknown_value">&lt;unknown&gt;</string>
<string name="settings_playback_mode">playback mode:</string>
<string name="settings_general">general:</string>
<string name="settings_enable_album_art">enable album art (uses last.fm)</string>
<string name="settings_enable_message_compression">enable message compression</string>
<string name="settings_enable_streaming_playback">enable streaming playback</string>
<string name="settings_enable_software_volume">use software volume while streaming</string>
<string name="unknown_artist">[unknown artist]</string>
<string name="unknown_album">[unknown album]</string>
<string name="unknown_title">[unknown title]</string>