Android client updates that support displaying time (including syncing

and extrapolating from server timestamps)
This commit is contained in:
casey langen 2017-06-03 01:32:05 -07:00
parent d7cbec1058
commit 629f8cb4a7
7 changed files with 256 additions and 61 deletions

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.Handler;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
@ -27,6 +28,7 @@ import io.casey.musikcube.remote.ui.fragment.InvalidPasswordDialogFragment;
import io.casey.musikcube.remote.ui.util.Views;
import io.casey.musikcube.remote.ui.view.LongPressTextView;
import io.casey.musikcube.remote.ui.view.MainMetadataView;
import io.casey.musikcube.remote.util.Duration;
import io.casey.musikcube.remote.websocket.Messages;
import io.casey.musikcube.remote.websocket.Prefs;
import io.casey.musikcube.remote.websocket.SocketMessage;
@ -37,11 +39,12 @@ public class MainActivity extends WebSocketActivityBase {
private WebSocketService wss = null;
private Handler handler = new Handler();
private SharedPreferences prefs;
private PlaybackService playback;
private MainMetadataView metadataView;
private TextView playPause;
private TextView playPause, currentTime, totalTime;
private View connectedNotPlaying, disconnectedButton;
private CheckBox shuffleCb, muteCb, repeatCb;
private View disconnectedOverlay;
@ -79,6 +82,7 @@ public class MainActivity extends WebSocketActivityBase {
super.onPause();
metadataView.onPause();
unbindCheckboxEventListeners();
handler.removeCallbacks(updateTimeRunnable);
}
@Override
@ -88,6 +92,7 @@ public class MainActivity extends WebSocketActivityBase {
metadataView.onResume();
bindCheckBoxEventListeners();
rebindUi();
scheduleUpdateTime(true);
}
@Override
@ -162,6 +167,8 @@ public class MainActivity extends WebSocketActivityBase {
this.disconnectedButton = findViewById(R.id.disconnected);
this.disconnectedOverlay = findViewById(R.id.disconnected_overlay);
this.playPause = (TextView) findViewById(R.id.button_play_pause);
this.currentTime = (TextView) findViewById(R.id.current_time);
this.totalTime = (TextView) findViewById(R.id.total_time);
findViewById(R.id.button_prev).setOnClickListener((View view) -> playback.prev());
@ -272,6 +279,17 @@ public class MainActivity extends WebSocketActivityBase {
startActivity(PlayQueueActivity.getStartIntent(MainActivity.this, playback.getQueuePosition()));
}
private void scheduleUpdateTime(boolean immediate) {
handler.removeCallbacks(updateTimeRunnable);
handler.postDelayed(updateTimeRunnable, immediate ? 0 : 1000);
}
private Runnable updateTimeRunnable = () -> {
currentTime.setText(Duration.format(playback.getCurrentTime()));
totalTime.setText(Duration.format(playback.getDuration()));
scheduleUpdateTime(false);
};
private CheckBox.OnCheckedChangeListener muteListener =
(CompoundButton compoundButton, boolean b) -> {
if (b != playback.isMuted()) {

View File

@ -1,6 +1,7 @@
package io.casey.musikcube.remote.playback;
import android.content.Context;
import android.os.Handler;
import org.json.JSONObject;
@ -13,7 +14,8 @@ import io.casey.musikcube.remote.websocket.SocketMessage;
import io.casey.musikcube.remote.websocket.WebSocketService;
public class RemotePlaybackService implements PlaybackService {
private WebSocketService wss;
private static final double NANOSECONDS_PER_SECOND = 1000000000.0;
private static final long SYNC_TIME_INTERVAL_MS = 5000L;
private interface Key {
String STATE = "state";
@ -28,6 +30,71 @@ public class RemotePlaybackService implements PlaybackService {
String PLAYING_TRACK = "playing_track";
}
/**
* an annoying little class that maintains and updates state that estimates
* the currently playing time. remember, here we're a remote control, so we
* don't know the exact position of the play head! we update every 5 seconds
* and estimate.
*/
private static class EstimatedPosition {
double lastTime = 0.0, pauseTime = 0.0;
long trackId = -1;
long queryTime = 0;
double get(final JSONObject track) {
if (track != null && track.optLong(Metadata.Track.ID, -1L) == trackId && trackId != -1) {
if (pauseTime != 0) {
return pauseTime;
}
else {
return estimatedTime();
}
}
return 0;
}
void update(final SocketMessage message) {
queryTime = System.nanoTime();
lastTime = message.getDoubleOption(Messages.Key.PLAYING_CURRENT_TIME, 0);
trackId = message.getLongOption(Messages.Key.ID, -1);
}
void pause() {
pauseTime = estimatedTime();
}
void resume() {
lastTime = pauseTime;
queryTime = System.nanoTime();
pauseTime = 0.0;
}
void update(final double time, final long id) {
queryTime = System.nanoTime();
lastTime = time;
trackId = id;
if (pauseTime != 0) {
pauseTime = time; /* ehh... */
}
}
void reset() {
lastTime = pauseTime = 0.0;
queryTime = System.nanoTime();
trackId = -1;
}
double estimatedTime() {
final long diff = System.nanoTime() - queryTime;
final double seconds = (double) diff / NANOSECONDS_PER_SECOND;
return lastTime + seconds;
}
}
private Handler handler = new Handler();
private WebSocketService wss;
private EstimatedPosition currentTime = new EstimatedPosition();
private PlaybackState playbackState = PlaybackState.Unknown;
private Set<EventListener> listeners = new HashSet<>();
private RepeatMode repeatMode;
@ -37,7 +104,6 @@ public class RemotePlaybackService implements PlaybackService {
private int queueCount;
private int queuePosition;
private double duration;
private double currentTime;
private JSONObject track = new JSONObject();
public RemotePlaybackService(final Context context) {
@ -170,6 +236,7 @@ public class RemotePlaybackService implements PlaybackService {
if (listeners.size() == 1) {
wss.addClient(client);
scheduleTimeSyncMessage();
}
}
}
@ -181,6 +248,7 @@ public class RemotePlaybackService implements PlaybackService {
if (listeners.size() == 0) {
wss.removeClient(client);
handler.removeCallbacks(syncTimeRunnable);
}
}
}
@ -226,7 +294,7 @@ public class RemotePlaybackService implements PlaybackService {
@Override
public double getCurrentTime() {
return currentTime;
return currentTime.get(track);
}
@Override
@ -256,11 +324,11 @@ public class RemotePlaybackService implements PlaybackService {
shuffled = muted = false;
volume = 0.0f;
queueCount = queuePosition = 0;
duration = currentTime = 0.0f;
track = new JSONObject();
currentTime.reset();
}
private boolean canHandle(SocketMessage socketMessage) {
private boolean isPlaybackOverviewMessage(SocketMessage socketMessage) {
if (socketMessage == null) {
return false;
}
@ -269,10 +337,10 @@ public class RemotePlaybackService implements PlaybackService {
return
name.equals(Messages.Broadcast.PlaybackOverviewChanged.toString()) ||
name.equals(Messages.Request.GetPlaybackOverview.toString());
name.equals(Messages.Request.GetPlaybackOverview.toString());
}
private boolean update(SocketMessage message) {
private boolean updatePlaybackOverview(SocketMessage message) {
if (message == null) {
reset();
return false;
@ -287,6 +355,17 @@ public class RemotePlaybackService implements PlaybackService {
}
playbackState = PlaybackState.from(message.getStringOption(Key.STATE));
switch (playbackState) {
case Paused:
currentTime.pause();
break;
case Playing:
currentTime.resume();
scheduleTimeSyncMessage();
break;
}
repeatMode = RepeatMode.from(message.getStringOption(Key.REPEAT_MODE));
shuffled = message.getBooleanOption(Key.SHUFFLED);
muted = message.getBooleanOption(Key.MUTED);
@ -294,9 +373,14 @@ public class RemotePlaybackService implements PlaybackService {
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());
if (track != null) {
currentTime.update(
message.getDoubleOption(Key.PLAYING_CURRENT_TIME, -1),
track.optLong(Metadata.Track.ID, -1));
}
notifyStateUpdated();
return true;
@ -308,6 +392,21 @@ public class RemotePlaybackService implements PlaybackService {
}
}
private void scheduleTimeSyncMessage() {
handler.removeCallbacks(syncTimeRunnable);
if (getPlaybackState() == PlaybackState.Playing) {
handler.postDelayed(syncTimeRunnable, SYNC_TIME_INTERVAL_MS);
}
}
private final Runnable syncTimeRunnable = () -> {
if (this.wss.hasClient(this.client)) {
this.wss.send(SocketMessage.Builder
.request(Messages.Request.GetCurrentTime).build());
}
};
private final TrackListSlidingWindow.QueryFactory queryFactory
= new TrackListSlidingWindow.QueryFactory() {
@Override
@ -343,8 +442,12 @@ public class RemotePlaybackService implements PlaybackService {
@Override
public void onMessageReceived(SocketMessage message) {
if (canHandle(message)) {
update(message);
if (isPlaybackOverviewMessage(message)) {
updatePlaybackOverview(message);
}
else if (Messages.Request.GetCurrentTime.is(message.getName())) {
currentTime.update(message);
scheduleTimeSyncMessage();
}
}

View File

@ -0,0 +1,15 @@
package io.casey.musikcube.remote.util;
import java.util.Locale;
public class Duration {
private Duration() {
}
public static String format(double seconds) {
final int mins = ((int) seconds / 60);
final int secs = (int) seconds - (mins * 60);
return String.format(Locale.getDefault(), "%d:%02d", mins, secs);
}
}

View File

@ -4,6 +4,7 @@ public class Messages {
public enum Request {
Authenticate("authenticate"),
Ping("ping"),
GetCurrentTime("get_current_time"),
GetPlaybackOverview("get_playback_overview"),
PauseOrResume("pause_or_resume"),
Stop("stop"),
@ -45,6 +46,10 @@ public class Messages {
return null;
}
public boolean is(final String name) {
return rawValue.equals(name);
}
}
public enum Broadcast {
@ -92,6 +97,7 @@ public class Messages {
String VALUE = "value";
String FILTER = "filter";
String RELATIVE = "relative";
String PLAYING_CURRENT_TIME = "playing_current_time";
}
public interface Value {

View File

@ -129,6 +129,21 @@ public class SocketMessage {
return defaultValue;
}
public long getLongOption(final String key) {
return getLongOption(key, 0);
}
public long getLongOption(final String key, final long defaultValue) {
if (options.has(key)) {
try {
return options.getLong(key);
}
catch (JSONException ex) {
}
}
return defaultValue;
}
public double getDoubleOption(final String key) {
return getDoubleOption(key, 0.0);

View File

@ -213,6 +213,11 @@ public class WebSocketService {
}
}
public boolean hasClient(Client client) {
Preconditions.throwIfNotOnMainThread();
return this.clients.contains(client);
}
public void reconnect() {
Preconditions.throwIfNotOnMainThread();
autoReconnect = true;

View File

@ -6,68 +6,53 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:clipToPadding="false"
tools:context="io.casey.musikcube.remote.MainActivity">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/play_controls">
<LinearLayout
android:id="@+id/connected_not_playing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@color/theme_green"
android:text="@string/button_connected"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="@dimen/text_size_large"
android:textColor="@color/theme_disabled_foreground"
android:text="@string/transport_not_playing"/>
</LinearLayout>
<TextView
android:id="@+id/disconnected"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="@dimen/text_size_large"
android:textColor="@color/theme_disabled_foreground"
android:padding="8dp"
android:clickable="true"
android:background="@drawable/not_playing_button"
android:text="@string/status_disconnected"/>
<io.casey.musikcube.remote.ui.view.MainMetadataView
android:id="@+id/main_metadata_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<FrameLayout
android:background="@color/theme_background"
android:id="@+id/play_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true">
android:layout_alignParentBottom="true"
android:elevation="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<android.support.v4.widget.Space
android:layout_width="0dp"
android:layout_height="2dp" />
<FrameLayout
android:background="@drawable/playback_button"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/current_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:gravity="left"
android:text="0:00"/>
<TextView
android:id="@+id/total_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right"
android:gravity="right"
android:text="0:00"/>
</FrameLayout>
<android.support.v4.widget.Space
android:layout_width="0dp"
android:layout_height="2dp" />
@ -274,5 +259,53 @@
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/play_controls">
<LinearLayout
android:id="@+id/connected_not_playing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="@color/theme_green"
android:text="@string/button_connected"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="@dimen/text_size_large"
android:textColor="@color/theme_disabled_foreground"
android:text="@string/transport_not_playing"/>
</LinearLayout>
<TextView
android:id="@+id/disconnected"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="@dimen/text_size_large"
android:textColor="@color/theme_disabled_foreground"
android:padding="8dp"
android:clickable="true"
android:background="@drawable/not_playing_button"
android:text="@string/status_disconnected"/>
<io.casey.musikcube.remote.ui.view.MainMetadataView
android:id="@+id/main_metadata_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
</RelativeLayout>