First chunk of work required to support offline playback.

This commit is contained in:
casey langen 2017-06-12 20:04:01 -07:00
parent 5222418123
commit 3bce961cb5
23 changed files with 531 additions and 133 deletions

View File

@ -30,6 +30,7 @@ android {
repositories {
flatDir { dirs 'libs' }
maven { url "https://jitpack.io" }
maven { url 'https://maven.google.com' }
mavenCentral()
}
@ -45,6 +46,9 @@ dependencies {
compile(name:'videocache-2.8.0-pre', ext:'aar')
compile 'org.slf4j:slf4j-android:1.7.21'
compile "android.arch.persistence.room:runtime:1.0.0-alpha2"
annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha2"
compile 'com.neovisionaries:nv-websocket-client:1.31'
compile 'com.squareup.okhttp3:okhttp:3.8.0'
compile 'com.github.bumptech.glide:glide:3.8.0'

View File

@ -1,28 +0,0 @@
package io.casey.musikcube.remote;
import com.facebook.stetho.Stetho;
import io.casey.musikcube.remote.playback.StreamProxy;
import io.casey.musikcube.remote.util.NetworkUtil;
public class Application extends android.app.Application {
private static Application instance;
@Override
public void onCreate() {
instance = this;
super.onCreate();
if (BuildConfig.DEBUG) {
Stetho.initializeWithDefaults(this);
}
NetworkUtil.init();
StreamProxy.init(this);
}
public static Application getInstance() {
return instance;
}
}

View File

@ -0,0 +1,37 @@
package io.casey.musikcube.remote
import android.arch.persistence.room.Room
import android.content.Context
import com.facebook.stetho.Stetho
import io.casey.musikcube.remote.offline.OfflineDb
import io.casey.musikcube.remote.playback.StreamProxy
import io.casey.musikcube.remote.util.NetworkUtil
class Application : android.app.Application() {
override fun onCreate() {
instance = this
super.onCreate()
if (BuildConfig.DEBUG) {
Stetho.initializeWithDefaults(this)
}
NetworkUtil.init()
StreamProxy.init(this)
offlineDb = Room.databaseBuilder(
applicationContext,
OfflineDb::class.java,
"offline").build()
}
companion object {
var instance: Context? = null
private set
var offlineDb: OfflineDb? = null
private set
}
}

View File

@ -123,6 +123,8 @@ public class MainActivity extends WebSocketActivityBase {
menu.findItem(R.id.action_remote_toggle).setIcon(
streaming ? R.mipmap.ic_toolbar_streaming : R.mipmap.ic_toolbar_remote);
menu.findItem(R.id.action_offline_tracks).setEnabled(streaming);
return super.onPrepareOptionsMenu(menu);
}
@ -145,6 +147,10 @@ public class MainActivity extends WebSocketActivityBase {
startActivity(CategoryBrowseActivity.getStartIntent(
this, Messages.Category.PLAYLISTS, CategoryBrowseActivity.DeepLink.TRACKS));
return true;
case R.id.action_offline_tracks:
startActivity(TrackListActivity.getOfflineStartIntent(this));
return true;
}
return super.onOptionsItemSelected(item);
@ -295,18 +301,19 @@ public class MainActivity extends WebSocketActivityBase {
throw new IllegalStateException();
}
final PlaybackState playbackState = playback.getPlaybackState();
final boolean streaming = prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK);
final boolean connected = (wss.getState() == WebSocketService.State.Connected);
final boolean stopped = (playback.getPlaybackState() == PlaybackState.Stopped);
final boolean playing = (playback.getPlaybackState() == PlaybackState.Playing);
final boolean buffering = (playback.getPlaybackState() == PlaybackState.Buffering);
final boolean showMetadataView = !stopped && connected && playback.getQueueCount() > 0;
final boolean stopped = (playbackState == PlaybackState.Stopped);
final boolean playing = (playbackState == PlaybackState.Playing);
final boolean buffering = (playbackState == PlaybackState.Buffering);
final boolean showMetadataView = !stopped && /*connected &&*/ playback.getQueueCount() > 0;
/* bottom section: transport controls */
this.playPause.setText(playing || buffering ? R.string.button_pause : R.string.button_play);
this.connectedNotPlaying.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE);
this.disconnectedOverlay.setVisibility(connected ? View.GONE : View.VISIBLE);
this.disconnectedOverlay.setVisibility(connected || !stopped ? View.GONE : View.VISIBLE);
final RepeatMode repeatMode = playback.getRepeatMode();
final boolean repeatChecked = (repeatMode != RepeatMode.None);

View File

@ -0,0 +1,102 @@
package io.casey.musikcube.remote.offline;
import android.arch.persistence.room.Database;
import android.arch.persistence.room.RoomDatabase;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import io.casey.musikcube.remote.Application;
import io.casey.musikcube.remote.playback.StreamProxy;
import io.casey.musikcube.remote.websocket.Messages;
import io.casey.musikcube.remote.websocket.SocketMessage;
import io.casey.musikcube.remote.websocket.WebSocketService;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
@Database(entities = {OfflineTrack.class}, version = 1)
public abstract class OfflineDb extends RoomDatabase {
public OfflineDb() {
WebSocketService.getInstance(Application.Companion.getInstance())
.addInterceptor((message, responder) -> {
if (Messages.Request.QueryTracksByCategory.is(message.getName())) {
final String category = message.getStringOption(Messages.Key.CATEGORY);
if (Messages.Category.OFFLINE.equals(category)) {
queryTracks(message, responder);
}
return true;
}
return false;
});
prune();
}
public abstract OfflineTrackDao trackDao();
public void prune() {
Single.fromCallable(() -> {
List<String> uris = trackDao().queryUris();
List<String> toDelete = new ArrayList<>();
for (final String uri : uris) {
if (!StreamProxy.isCached(uri)) {
toDelete.add(uri);
}
}
if (toDelete.size() > 0) {
trackDao().deleteByUri(toDelete);
}
return true;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
public void queryTracks(final SocketMessage message, final WebSocketService.Responder responder) {
Single.fromCallable(() -> {
final OfflineTrackDao dao = trackDao();
final boolean countOnly = message.getBooleanOption(Messages.Key.COUNT_ONLY, false);
final JSONArray tracks = new JSONArray();
final JSONObject options = new JSONObject();
if (countOnly) {
options.put(Messages.Key.COUNT, dao.countTracks());
}
else {
final int offset = message.getIntOption(Messages.Key.OFFSET, -1);
final int limit = message.getIntOption(Messages.Key.LIMIT, -1);
final List<OfflineTrack> offlineTracks = (offset == -1 || limit == -1)
? dao.queryTracks() : dao.queryTracks(limit, offset);
for (final OfflineTrack track : offlineTracks) {
tracks.put(track.toJSONObject());
}
options.put(Messages.Key.OFFSET, offset);
options.put(Messages.Key.LIMIT, limit);
}
options.put(Messages.Key.DATA, tracks);
responder.respond(SocketMessage.Builder
.respondTo(message).withOptions(options).build());
return true;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
}

View File

@ -0,0 +1,68 @@
package io.casey.musikcube.remote.offline;
import android.arch.persistence.room.Entity;
import android.arch.persistence.room.PrimaryKey;
import org.json.JSONException;
import org.json.JSONObject;
import io.casey.musikcube.remote.playback.Metadata;
import io.casey.musikcube.remote.util.Strings;
@Entity
public class OfflineTrack {
@PrimaryKey
public String externalId;
public String uri, title, album, artist, albumArtist, genre;
public long albumId, artistId, albumArtistId, genreId;
public int trackNum;
public boolean fromJSONObject(final String uri, final JSONObject from) {
if (Strings.empty(uri)) {
throw new IllegalArgumentException("uri cannot be empty");
}
final String externalId = from.optString(Metadata.Track.EXTERNAL_ID, "");
if (Strings.empty(externalId)) {
return false;
}
this.externalId = externalId;
this.uri = uri;
this.title = from.optString(Metadata.Track.TITLE, "");
this.album = from.optString(Metadata.Track.ALBUM, "");
this.artist = from.optString(Metadata.Track.ARTIST, "");
this.albumArtist = from.optString(Metadata.Track.ALBUM_ARTIST, "");
this.genre = from.optString(Metadata.Track.GENRE, "");
this.albumId = from.optLong(Metadata.Track.ALBUM_ID, -1L);
this.artistId = from.optLong(Metadata.Track.ARTIST_ID, -1L);
this.albumArtistId = from.optLong(Metadata.Track.ALBUM_ARTIST_ID, -1L);
this.genreId = from.optLong(Metadata.Track.GENRE_ID, -1L);
this.trackNum = from.optInt(Metadata.Track.TRACK_NUM, 0);
return true;
}
JSONObject toJSONObject() {
try {
final JSONObject json = new JSONObject();
json.put(Metadata.Track.TITLE, title);
json.put(Metadata.Track.ALBUM, album);
json.put(Metadata.Track.ARTIST, artist);
json.put(Metadata.Track.ALBUM_ARTIST, albumArtist);
json.put(Metadata.Track.GENRE, genre);
json.put(Metadata.Track.ALBUM_ID, albumId);
json.put(Metadata.Track.ARTIST_ID, artistId);
json.put(Metadata.Track.ALBUM_ARTIST_ID, albumArtistId);
json.put(Metadata.Track.GENRE_ID, genreId);
json.put(Metadata.Track.TRACK_NUM, trackNum);
json.put(Metadata.Track.EXTERNAL_ID, externalId);
json.put(Metadata.Track.URI, uri);
return json;
}
catch (JSONException ex) {
throw new RuntimeException("json serialization error");
}
}
}

View File

@ -0,0 +1,32 @@
package io.casey.musikcube.remote.offline;
import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Insert;
import android.arch.persistence.room.OnConflictStrategy;
import android.arch.persistence.room.Query;
import java.util.List;
@Dao
public interface OfflineTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertTrack(OfflineTrack... track);
@Query("SELECT * FROM OfflineTrack " +
"ORDER BY albumArtist ASC, album ASC, trackNum ASC, TITLE ASC " +
"LIMIT :limit OFFSET :offset")
List<OfflineTrack> queryTracks(int limit, int offset);
@Query("SELECT * FROM OfflineTrack " +
"ORDER BY albumArtist ASC, album ASC, trackNum ASC, TITLE ASC")
List<OfflineTrack> queryTracks();
@Query("SELECT COUNT(*) FROM OfflineTrack")
int countTracks();
@Query("SELECT DISTINCT uri FROM OfflineTrack")
List<String> queryUris();
@Query("DELETE FROM OfflineTrack WHERE uri IN(:uris)")
void deleteByUri(List<String> uris);
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.util.Base64
import android.util.Log
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
@ -25,6 +26,7 @@ import io.casey.musikcube.remote.websocket.Prefs
import io.casey.musikcube.remote.playback.StreamProxy.*
import okhttp3.Cache
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.File
class ExoPlayerWrapper : PlayerWrapper() {
@ -33,12 +35,13 @@ class ExoPlayerWrapper : PlayerWrapper() {
private val extractors: ExtractorsFactory
private var source: MediaSource? = null
private val player: SimpleExoPlayer?
private var metadata: JSONObject? = null
private var prefetch: Boolean = false
private val context: Context
private var lastPosition: Long = -1
private var percentAvailable = 0
private var originalUri: String? = null
private var resolvedUri: String? = null
private var proxyUri: String? = null
private val transcoding: Boolean
private fun initHttpClient(uri: String) {
@ -88,26 +91,31 @@ class ExoPlayerWrapper : PlayerWrapper() {
}
init {
this.context = Application.getInstance()
this.context = Application.instance!!
val bandwidth = DefaultBandwidthMeter()
val trackFactory = AdaptiveTrackSelection.Factory(bandwidth)
val trackSelector = DefaultTrackSelector(trackFactory)
this.player = ExoPlayerFactory.newSimpleInstance(this.context, trackSelector)
this.extractors = DefaultExtractorsFactory()
this.datasources = DefaultDataSourceFactory(context, Util.getUserAgent(context, "musikdroid"))
this.prefs = Application.getInstance().getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
this.prefs = Application.instance!!.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
this.transcoding = this.prefs.getInt(Prefs.Key.TRANSCODER_BITRATE_INDEX, 0) != 0
}
override fun play(uri: String) {
override fun play(uri: String, metadata: JSONObject) {
Preconditions.throwIfNotOnMainThread()
if (!dead()) {
initHttpClient(uri)
this.metadata = metadata
this.originalUri = uri
this.resolvedUri = StreamProxy.getProxyUrl(context, uri)
this.proxyUri = StreamProxy.getProxyUrl(context, uri)
Log.d("ExoPlayerWrapper", "originalUri: ${this.originalUri} proxyUri: ${this.proxyUri}")
addCacheListener()
this.source = ExtractorMediaSource(Uri.parse(resolvedUri), datasources, extractors, null, null)
this.source = ExtractorMediaSource(Uri.parse(proxyUri), datasources, extractors, null, null)
this.player!!.playWhenReady = true
this.player.prepare(this.source)
PlayerWrapper.addActivePlayer(this)
@ -115,16 +123,22 @@ class ExoPlayerWrapper : PlayerWrapper() {
}
}
override fun prefetch(uri: String) {
override fun prefetch(uri: String, metadata: JSONObject) {
Preconditions.throwIfNotOnMainThread()
if (!dead()) {
initHttpClient(uri)
this.metadata = metadata
this.originalUri = uri
this.proxyUri = StreamProxy.getProxyUrl(context, uri)
Log.d("ExoPlayerWrapper", "originalUri: ${this.originalUri} proxyUri: ${this.proxyUri}")
this.prefetch = true
this.resolvedUri = StreamProxy.getProxyUrl(context, uri)
addCacheListener()
this.source = ExtractorMediaSource(Uri.parse(resolvedUri), datasources, extractors, null, null)
this.source = ExtractorMediaSource(Uri.parse(proxyUri), datasources, extractors, null, null)
this.player!!.playWhenReady = false
this.player.prepare(this.source)
PlayerWrapper.addActivePlayer(this)
@ -239,6 +253,10 @@ class ExoPlayerWrapper : PlayerWrapper() {
if (StreamProxy.ENABLED) {
if (StreamProxy.isCached(this.originalUri)) {
percentAvailable = 100
if (originalUri != null && metadata != null) {
PlayerWrapper.storeOffline(originalUri!!, metadata!!)
}
}
else {
StreamProxy.registerCacheListener(this.cacheListener, this.originalUri)
@ -258,6 +276,12 @@ class ExoPlayerWrapper : PlayerWrapper() {
private val cacheListener = { _: File, _: String, percent: Int ->
//Log.e("CLCLCL", String.format("%d", percent));
percentAvailable = percent
if (percentAvailable >= 100) {
if (originalUri != null && metadata != null) {
PlayerWrapper.storeOffline(originalUri!!, metadata!!)
}
}
}
private var eventListener = object : ExoPlayer.EventListener {
@ -293,7 +317,8 @@ class ExoPlayerWrapper : PlayerWrapper() {
if (!prefetch) {
player.playWhenReady = true
state = State.Playing
} else {
}
else {
state = State.Paused
}
}

View File

@ -15,21 +15,24 @@ import java.util.HashMap
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.util.Preconditions
import io.casey.musikcube.remote.websocket.Prefs
import org.json.JSONObject
class MediaPlayerWrapper : PlayerWrapper() {
private val player = MediaPlayer()
private var seekTo: Int = 0
private var prefetching: Boolean = false
private val context = Application.getInstance()
private val context = Application.instance
private val prefs: SharedPreferences
private var metadata: JSONObject? = null
private var proxyUri: String? = null
private var originalUri: String? = null
override var bufferedPercent: Int = 0
init {
this.prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
this.prefs = context!!.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
}
override fun play(uri: String) {
override fun play(uri: String, metadata: JSONObject) {
Preconditions.throwIfNotOnMainThread()
try {
@ -40,17 +43,17 @@ class MediaPlayerWrapper : PlayerWrapper() {
val headers = HashMap<String, String>()
headers.put("Authorization", "Basic " + encoded)
player.setDataSource(
context,
Uri.parse(StreamProxy.getProxyUrl(context, uri)),
headers)
this.metadata = metadata
this.originalUri = uri
this.proxyUri = StreamProxy.getProxyUrl(context, uri)
player.setDataSource(context, Uri.parse(proxyUri), headers)
player.setAudioStreamType(AudioManager.STREAM_MUSIC)
player.setOnPreparedListener(onPrepared)
player.setOnErrorListener(onError)
player.setOnCompletionListener(onCompleted)
player.setOnBufferingUpdateListener(onBuffering)
player.setWakeMode(Application.getInstance(), PowerManager.PARTIAL_WAKE_LOCK)
player.setWakeMode(Application.instance, PowerManager.PARTIAL_WAKE_LOCK)
player.prepareAsync()
}
catch (e: IOException) {
@ -58,11 +61,11 @@ class MediaPlayerWrapper : PlayerWrapper() {
}
}
override fun prefetch(uri: String) {
override fun prefetch(uri: String, metadata: JSONObject) {
Preconditions.throwIfNotOnMainThread()
this.prefetching = true
play(uri)
play(uri, metadata)
}
override fun pause() {
@ -186,7 +189,7 @@ class MediaPlayerWrapper : PlayerWrapper() {
}
}
private val onPrepared = { mediaPlayer: MediaPlayer ->
private val onPrepared = { _: MediaPlayer ->
if (this.state === State.Killing) {
dispose()
}
@ -224,7 +227,15 @@ class MediaPlayerWrapper : PlayerWrapper() {
dispose()
}
private val onBuffering = { _: MediaPlayer, percent: Int -> bufferedPercent = percent }
private val onBuffering = { _: MediaPlayer, percent: Int ->
bufferedPercent = percent
if (bufferedPercent >= 100) {
if (originalUri != null && metadata != null) {
PlayerWrapper.storeOffline(originalUri!!, metadata!!)
}
}
}
companion object {
private val TAG = "MediaPlayerWrapper"

View File

@ -3,12 +3,15 @@ package io.casey.musikcube.remote.playback;
public class Metadata {
public interface Track {
String ID = "id";
String EXTERNAL_ID = "external_id";
String URI = "uri";
String TITLE = "title";
String ALBUM = "album";
String ALBUM_ID = "album_id";
String ALBUM_ARTIST = "album_artist";
String ALBUM_ARTIST_ID = "album_artist_id";
String GENRE = "genre";
String TRACK_NUM = "track_num";
String GENRE_ID = "visual_genre_id";
String ARTIST = "artist";
String ARTIST_ID = "visual_artist_id";

View File

@ -1,7 +1,6 @@
package io.casey.musikcube.remote.playback;
public enum PlaybackState {
Unknown("unknown"),
Stopped("stopped"),
Buffering("buffering"), /* streaming only */
Playing("playing"),
@ -19,7 +18,7 @@ public enum PlaybackState {
}
static PlaybackState from(final String rawValue) {
if (Stopped.rawValue.equals(rawValue)) {
if (Stopped.rawValue.equals(rawValue) || "unknown".equals(rawValue)) {
return Stopped;
}
else if (Playing.rawValue.equals(rawValue)) {

View File

@ -1,8 +1,14 @@
package io.casey.musikcube.remote.playback
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.offline.OfflineTrack
import java.util.HashSet
import io.casey.musikcube.remote.util.Preconditions
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import org.json.JSONObject
abstract class PlayerWrapper {
private enum class Type {
@ -38,8 +44,8 @@ abstract class PlayerWrapper {
}
}
abstract fun play(uri: String)
abstract fun prefetch(uri: String)
abstract fun play(uri: String, metadata: JSONObject)
abstract fun prefetch(uri: String, metadata: JSONObject)
abstract fun pause()
abstract fun resume()
abstract fun updateVolume()
@ -69,6 +75,19 @@ abstract class PlayerWrapper {
private var globalMuted = false
private var preDuckGlobalVolume = DUCK_NONE
fun storeOffline(uri: String, json: JSONObject) {
Single.fromCallable {
val track = OfflineTrack()
if (track.fromJSONObject(uri, json)) {
Application.offlineDb?.trackDao()?.insertTrack(track)
Application.offlineDb?.prune()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
}
fun duck() {
Preconditions.throwIfNotOnMainThread()

View File

@ -95,7 +95,7 @@ public class RemotePlaybackService implements PlaybackService {
private Handler handler = new Handler();
private WebSocketService wss;
private EstimatedPosition currentTime = new EstimatedPosition();
private PlaybackState playbackState = PlaybackState.Unknown;
private PlaybackState playbackState = PlaybackState.Stopped;
private Set<EventListener> listeners = new HashSet<>();
private RepeatMode repeatMode;
private boolean shuffled;
@ -333,7 +333,7 @@ public class RemotePlaybackService implements PlaybackService {
}
private void reset() {
playbackState = PlaybackState.Unknown;
playbackState = PlaybackState.Stopped;
repeatMode = RepeatMode.None;
shuffled = muted = false;
volume = 0.0f;
@ -439,6 +439,11 @@ public class RemotePlaybackService implements PlaybackService {
.addOption(Messages.Key.LIMIT, limit)
.build();
}
@Override
public boolean connectionRequired() {
return true;
}
};
private WebSocketService.Client client = new WebSocketService.Client() {

View File

@ -156,7 +156,7 @@ public class StreamingPlaybackService implements PlaybackService {
public StreamingPlaybackService(final Context context) {
this.wss = WebSocketService.getInstance(context.getApplicationContext());
this.prefs = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE);
this.audioManager = (AudioManager) Application.getInstance().getSystemService(Context.AUDIO_SERVICE);
this.audioManager = (AudioManager) Application.Companion.getInstance().getSystemService(Context.AUDIO_SERVICE);
this.lastSystemVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
this.repeatMode = RepeatMode.from(this.prefs.getString(REPEAT_MODE_PREF, RepeatMode.None.toString()));
this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
@ -570,7 +570,12 @@ public class StreamingPlaybackService implements PlaybackService {
private String getUri(final JSONObject track) {
if (track != null) {
final String externalId = track.optString("external_id", "");
final String existingUri = track.optString(Metadata.Track.URI, "");
if (Strings.notEmpty(existingUri)) {
return existingUri;
}
final String externalId = track.optString(Metadata.Track.EXTERNAL_ID, "");
if (Strings.notEmpty(externalId)) {
final String protocol = prefs.getBoolean(
Prefs.Key.SSL_ENABLED, Prefs.Default.SSL_ENABLED) ? "https" : "http";
@ -582,7 +587,7 @@ public class StreamingPlaybackService implements PlaybackService {
Prefs.Default.TRANSCODER_BITRATE_INDEX);
if (bitrateIndex > 0) {
final Resources r = Application.getInstance().getResources();
final Resources r = Application.Companion.getInstance().getResources();
bitrateQueryParam = String.format(
Locale.ENGLISH,
@ -701,7 +706,7 @@ public class StreamingPlaybackService implements PlaybackService {
this.context.reset(this.context.nextPlayer);
this.context.nextPlayer = PlayerWrapper.Companion.newInstance();
this.context.nextPlayer.setOnStateChangedListener(onNextPlayerStateChanged);
this.context.nextPlayer.prefetch(uri);
this.context.nextPlayer.prefetch(uri, this.context.nextMetadata);
}
}
}
@ -794,7 +799,7 @@ public class StreamingPlaybackService implements PlaybackService {
if (uri != null) {
this.context.currentPlayer = PlayerWrapper.Companion.newInstance();
this.context.currentPlayer.setOnStateChangedListener(onCurrentPlayerStateChanged);
this.context.currentPlayer.play(uri);
this.context.currentPlayer.play(uri, this.context.currentMetadata);
}
}
})
@ -885,6 +890,11 @@ public class StreamingPlaybackService implements PlaybackService {
return null;
}
@Override
public boolean connectionRequired() {
return true;
}
};
private WebSocketService.Client wssClient = new WebSocketService.Client() {

View File

@ -71,17 +71,17 @@ public class SystemService extends Service {
private SimpleTarget<Bitmap> albumArtRequest;
public static void wakeup() {
final Context c = Application.getInstance();
final Context c = Application.Companion.getInstance();
c.startService(new Intent(c, SystemService.class).setAction(ACTION_WAKE_UP));
}
public static void shutdown() {
final Context c = Application.getInstance();
final Context c = Application.Companion.getInstance();
c.startService(new Intent(c, SystemService.class).setAction(ACTION_SHUT_DOWN));
}
public static void sleep() {
final Context c = Application.getInstance();
final Context c = Application.Companion.getInstance();
c.startService(new Intent(c, SystemService.class).setAction(ACTION_SLEEP));
}

View File

@ -23,6 +23,8 @@ import io.casey.musikcube.remote.websocket.Messages;
import io.casey.musikcube.remote.websocket.SocketMessage;
import io.casey.musikcube.remote.websocket.WebSocketService;
import static io.casey.musikcube.remote.ui.model.TrackListSlidingWindow.QueryFactory;
public class PlayQueueActivity extends WebSocketActivityBase {
private static String EXTRA_PLAYING_INDEX = "extra_playing_index";
@ -35,6 +37,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
private TrackListSlidingWindow<JSONObject> tracks;
private PlaybackService playback;
private Adapter adapter;
private boolean offlineQueue;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@ -54,11 +57,16 @@ public class PlayQueueActivity extends WebSocketActivityBase {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter);
final QueryFactory queryFactory = this.playback.getPlaylistQueryFactory();
offlineQueue = Messages.Category.OFFLINE.equals(
queryFactory.getRequeryMessage().getStringOption(Messages.Key.CATEGORY));
this.tracks = new TrackListSlidingWindow<>(
recyclerView,
fastScroller,
this.wss,
this.playback.getPlaylistQueryFactory(),
queryFactory,
(JSONObject obj) -> obj);
this.tracks.setInitialPosition(
@ -110,7 +118,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
private final WebSocketService.Client webSocketClient = new WebSocketService.Client() {
@Override
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
if (newState == WebSocketService.State.Connected) {
if (newState == WebSocketService.State.Connected || offlineQueue) {
tracks.requery();
}
}
@ -147,10 +155,13 @@ public class PlayQueueActivity extends WebSocketActivityBase {
subtitle.setText("-");
}
else {
long playingId = playback.getTrackLong(Messages.Key.ID, -1);
long entryId = entry.optLong(Messages.Key.ID, -1);
final String entryExternalId = entry
.optString(Metadata.Track.EXTERNAL_ID, "");
if (entryId != -1 && playingId == entryId) {
final String playingExternalId = playback
.getTrackString(Metadata.Track.EXTERNAL_ID, "");
if (entryExternalId.equals(playingExternalId)) {
titleColor = R.color.theme_green;
subtitleColor = R.color.theme_yellow;
}

View File

@ -40,6 +40,10 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
.putExtra(EXTRA_SELECTED_ID, id);
}
public static Intent getOfflineStartIntent(final Context context) {
return getStartIntent(context, Messages.Category.OFFLINE, 0);
}
public static Intent getStartIntent(final Context context,
final String type,
final long id,
@ -135,7 +139,7 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
private WebSocketService.Client socketServiceClient = new WebSocketService.Client() {
@Override
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
if (newState == WebSocketService.State.Connected) {
if (canRequery()) {
filterDebouncer.cancel();
tracks.requery();
}
@ -192,10 +196,13 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
int subtitleColor = R.color.theme_disabled_foreground;
if (entry != null) {
long playingId = transport.getPlaybackService().getTrackLong(Messages.Key.ID, -1);
long entryId = entry.optLong(Messages.Key.ID, -1);
final String entryExternalId = entry
.optString(Metadata.Track.EXTERNAL_ID, "");
if (entryId != -1 && playingId == entryId) {
final String playingExternalId = transport.getPlaybackService()
.getTrackString(Metadata.Track.EXTERNAL_ID, "");
if (entryExternalId.equals(playingExternalId)) {
titleColor = R.color.theme_green;
subtitleColor = R.color.theme_yellow;
}
@ -237,6 +244,12 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
return categoryType != null && categoryType.length() > 0 && categoryId != -1;
}
private boolean canRequery() {
return
getWebSocketService().getState() == WebSocketService.State.Connected ||
Messages.Category.OFFLINE.equals(categoryType);
}
private QueryFactory createCategoryQueryFactory(
final String categoryType, long categoryId) {
@ -265,6 +278,11 @@ public class TrackListActivity extends WebSocketActivityBase implements Filterab
.addOption(Messages.Key.FILTER, lastFilter)
.build();
}
@Override
public boolean connectionRequired() {
return Messages.Category.OFFLINE.equals(categoryType);
}
};
}
else {

View File

@ -54,9 +54,13 @@ public class TrackListSlidingWindow<TrackType> {
void onReloaded(int count);
}
public interface QueryFactory {
SocketMessage getRequeryMessage();
SocketMessage getPageAroundMessage(int offset, int limit);
public static abstract class QueryFactory {
public abstract SocketMessage getRequeryMessage();
public abstract SocketMessage getPageAroundMessage(int offset, int limit);
public boolean connectionRequired() {
return false;
}
}
public TrackListSlidingWindow(RecyclerView recyclerView,
@ -86,7 +90,9 @@ public class TrackListSlidingWindow<TrackType> {
};
public void requery() {
if (connected) {
boolean connectionRequired = (queryFactory != null) && queryFactory.connectionRequired();
if (!connectionRequired || connected) {
cancelMessages();
boolean queried = false;

View File

@ -108,6 +108,7 @@ public class Messages {
}
public interface Category {
String OFFLINE = "offline";
String ALBUM = "album";
String ARTIST = "artist";
String ALBUM_ARTIST = "album_artist";

View File

@ -286,6 +286,16 @@ public class SocketMessage {
return builder;
}
public Builder withOptions(final JSONObject options) {
if (options == null) {
this.options = new JSONObject();
}
else {
this.options = options;
}
return this;
}
public Builder addOption(final String key, final Object value) {
try {
options.put(key, value);

View File

@ -73,6 +73,14 @@ public class WebSocketService {
boolean check(T value);
}
public interface Interceptor {
boolean process(SocketMessage message, Responder responder);
}
public interface Responder {
void respond(SocketMessage response);
}
public enum State {
Connecting,
Connected,
@ -169,6 +177,7 @@ public class WebSocketService {
private boolean autoReconnect = false;
private NetworkChangedReceiver networkChanged = new NetworkChangedReceiver();
private ConnectThread thread;
private Set<Interceptor> interceptors = new HashSet<>();
public static synchronized WebSocketService getInstance(final Context context) {
if (INSTANCE == null) {
@ -184,6 +193,16 @@ public class WebSocketService {
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS);
}
public void addInterceptor(final Interceptor interceptor) {
Preconditions.throwIfNotOnMainThread();
interceptors.add(interceptor);
}
public void removeInterceptor(final Interceptor interceptor) {
Preconditions.throwIfNotOnMainThread();
interceptors.remove(interceptor);
}
public void addClient(Client client) {
Preconditions.throwIfNotOnMainThread();
@ -267,15 +286,28 @@ public class WebSocketService {
public long send(final SocketMessage message, Client client, MessageResultCallback callback) {
Preconditions.throwIfNotOnMainThread();
if (this.socket != null) {
boolean intercepted = false;
for (final Interceptor i : interceptors) {
if (i.process(message, responder)) {
intercepted = true;
}
}
if (!intercepted) {
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
a reconnect until we can find a better root cause. this is very difficult to repro */
if (!this.socket.isOpen()) {
if (this.socket != null && !this.socket.isOpen()) {
this.disconnect(true);
return -1;
}
else {
long id = NEXT_ID.incrementAndGet();
else if (this.socket == null) {
return -1;
}
}
final long id = NEXT_ID.incrementAndGet();
if (callback != null) {
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
@ -290,12 +322,11 @@ public class WebSocketService {
messageCallbacks.put(message.getId(), mrd);
}
if (!intercepted) {
this.socket.sendText(message.toString());
return id;
}
}
return -1;
return id;
}
public Observable<SocketMessage> send(final SocketMessage message, Client client) {
@ -305,15 +336,27 @@ public class WebSocketService {
try {
Preconditions.throwIfNotOnMainThread();
if (socket != null) {
boolean intercepted = false;
for (final Interceptor i : interceptors) {
if (i.process(message, responder)) {
intercepted = true;
}
}
if (!intercepted) {
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
a reconnect until we can find a better root cause. this is very difficult to repro */
if (!socket.isOpen()) {
if (socket != null && !socket.isOpen()) {
disconnect(true);
throw new Exception("socket disconnected");
}
else {
else if (socket == null) {
throw new Exception("socket not connected");
}
}
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
throw new IllegalArgumentException("client is not registered");
}
@ -335,10 +378,11 @@ public class WebSocketService {
};
messageCallbacks.put(message.getId(), mrd);
if (!intercepted) {
socket.sendText(message.toString());
}
}
}
catch (Exception ex) {
emitter.onError(ex);
}
@ -486,6 +530,14 @@ public class WebSocketService {
private Runnable autoDisconnectRunnable = () -> disconnect();
private Responder responder = (response) -> {
/* post to the back of the queue in case the interceptor responded immediately;
we need to ensure all of the request book-keeping has been finished. */
handler.post(() -> {
handler.sendMessage(Message.obtain(handler, MESSAGE_MESSAGE_RECEIVED, response));
});
};
private WebSocketAdapter webSocketAdapter = new WebSocketAdapter() {
@Override
public void onTextMessage(WebSocket websocket, String text) throws Exception {

View File

@ -24,4 +24,9 @@
app:showAsAction="never"
android:title="@string/menu_settings"/>
<item
android:id="@+id/action_offline_tracks"
app:showAsAction="never"
android:title="@string/menu_offline_tracks"/>
</menu>

View File

@ -53,6 +53,7 @@
<string name="menu_genres">genres</string>
<string name="menu_playlists">playlists</string>
<string name="menu_remote_toggle">remote playback</string>
<string name="menu_offline_tracks">offline tracks</string>
<string name="unknown_value">&lt;unknown&gt;</string>
<string name="snackbar_streaming_enabled">switched to streaming mode</string>
<string name="snackbar_remote_enabled">switched to remote control mode</string>