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 { repositories {
flatDir { dirs 'libs' } flatDir { dirs 'libs' }
maven { url "https://jitpack.io" } maven { url "https://jitpack.io" }
maven { url 'https://maven.google.com' }
mavenCentral() mavenCentral()
} }
@ -45,6 +46,9 @@ dependencies {
compile(name:'videocache-2.8.0-pre', ext:'aar') compile(name:'videocache-2.8.0-pre', ext:'aar')
compile 'org.slf4j:slf4j-android:1.7.21' 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.neovisionaries:nv-websocket-client:1.31'
compile 'com.squareup.okhttp3:okhttp:3.8.0' compile 'com.squareup.okhttp3:okhttp:3.8.0'
compile 'com.github.bumptech.glide:glide: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( menu.findItem(R.id.action_remote_toggle).setIcon(
streaming ? R.mipmap.ic_toolbar_streaming : R.mipmap.ic_toolbar_remote); streaming ? R.mipmap.ic_toolbar_streaming : R.mipmap.ic_toolbar_remote);
menu.findItem(R.id.action_offline_tracks).setEnabled(streaming);
return super.onPrepareOptionsMenu(menu); return super.onPrepareOptionsMenu(menu);
} }
@ -145,6 +147,10 @@ public class MainActivity extends WebSocketActivityBase {
startActivity(CategoryBrowseActivity.getStartIntent( startActivity(CategoryBrowseActivity.getStartIntent(
this, Messages.Category.PLAYLISTS, CategoryBrowseActivity.DeepLink.TRACKS)); this, Messages.Category.PLAYLISTS, CategoryBrowseActivity.DeepLink.TRACKS));
return true; return true;
case R.id.action_offline_tracks:
startActivity(TrackListActivity.getOfflineStartIntent(this));
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
@ -295,18 +301,19 @@ public class MainActivity extends WebSocketActivityBase {
throw new IllegalStateException(); throw new IllegalStateException();
} }
final PlaybackState playbackState = playback.getPlaybackState();
final boolean streaming = prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK); final boolean streaming = prefs.getBoolean(Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK);
final boolean connected = (wss.getState() == WebSocketService.State.Connected); final boolean connected = (wss.getState() == WebSocketService.State.Connected);
final boolean stopped = (playback.getPlaybackState() == PlaybackState.Stopped); final boolean stopped = (playbackState == PlaybackState.Stopped);
final boolean playing = (playback.getPlaybackState() == PlaybackState.Playing); final boolean playing = (playbackState == PlaybackState.Playing);
final boolean buffering = (playback.getPlaybackState() == PlaybackState.Buffering); final boolean buffering = (playbackState == PlaybackState.Buffering);
final boolean showMetadataView = !stopped && connected && playback.getQueueCount() > 0; final boolean showMetadataView = !stopped && /*connected &&*/ playback.getQueueCount() > 0;
/* bottom section: transport controls */ /* bottom section: transport controls */
this.playPause.setText(playing || buffering ? R.string.button_pause : R.string.button_play); this.playPause.setText(playing || buffering ? R.string.button_pause : R.string.button_play);
this.connectedNotPlaying.setVisibility((connected && stopped) ? View.VISIBLE : View.GONE); 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 RepeatMode repeatMode = playback.getRepeatMode();
final boolean repeatChecked = (repeatMode != RepeatMode.None); 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.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import android.util.Log
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory 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 io.casey.musikcube.remote.playback.StreamProxy.*
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.File import java.io.File
class ExoPlayerWrapper : PlayerWrapper() { class ExoPlayerWrapper : PlayerWrapper() {
@ -33,12 +35,13 @@ class ExoPlayerWrapper : PlayerWrapper() {
private val extractors: ExtractorsFactory private val extractors: ExtractorsFactory
private var source: MediaSource? = null private var source: MediaSource? = null
private val player: SimpleExoPlayer? private val player: SimpleExoPlayer?
private var metadata: JSONObject? = null
private var prefetch: Boolean = false private var prefetch: Boolean = false
private val context: Context private val context: Context
private var lastPosition: Long = -1 private var lastPosition: Long = -1
private var percentAvailable = 0 private var percentAvailable = 0
private var originalUri: String? = null private var originalUri: String? = null
private var resolvedUri: String? = null private var proxyUri: String? = null
private val transcoding: Boolean private val transcoding: Boolean
private fun initHttpClient(uri: String) { private fun initHttpClient(uri: String) {
@ -88,26 +91,31 @@ class ExoPlayerWrapper : PlayerWrapper() {
} }
init { init {
this.context = Application.getInstance() this.context = Application.instance!!
val bandwidth = DefaultBandwidthMeter() val bandwidth = DefaultBandwidthMeter()
val trackFactory = AdaptiveTrackSelection.Factory(bandwidth) val trackFactory = AdaptiveTrackSelection.Factory(bandwidth)
val trackSelector = DefaultTrackSelector(trackFactory) val trackSelector = DefaultTrackSelector(trackFactory)
this.player = ExoPlayerFactory.newSimpleInstance(this.context, trackSelector) this.player = ExoPlayerFactory.newSimpleInstance(this.context, trackSelector)
this.extractors = DefaultExtractorsFactory() this.extractors = DefaultExtractorsFactory()
this.datasources = DefaultDataSourceFactory(context, Util.getUserAgent(context, "musikdroid")) 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 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() Preconditions.throwIfNotOnMainThread()
if (!dead()) { if (!dead()) {
initHttpClient(uri) initHttpClient(uri)
this.metadata = metadata
this.originalUri = uri 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() 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!!.playWhenReady = true
this.player.prepare(this.source) this.player.prepare(this.source)
PlayerWrapper.addActivePlayer(this) PlayerWrapper.addActivePlayer(this)
@ -115,16 +123,22 @@ class ExoPlayerWrapper : PlayerWrapper() {
} }
} }
override fun prefetch(uri: String) { override fun prefetch(uri: String, metadata: JSONObject) {
Preconditions.throwIfNotOnMainThread() Preconditions.throwIfNotOnMainThread()
if (!dead()) { if (!dead()) {
initHttpClient(uri) initHttpClient(uri)
this.metadata = metadata
this.originalUri = uri this.originalUri = uri
this.proxyUri = StreamProxy.getProxyUrl(context, uri)
Log.d("ExoPlayerWrapper", "originalUri: ${this.originalUri} proxyUri: ${this.proxyUri}")
this.prefetch = true this.prefetch = true
this.resolvedUri = StreamProxy.getProxyUrl(context, uri)
addCacheListener() 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!!.playWhenReady = false
this.player.prepare(this.source) this.player.prepare(this.source)
PlayerWrapper.addActivePlayer(this) PlayerWrapper.addActivePlayer(this)
@ -239,6 +253,10 @@ class ExoPlayerWrapper : PlayerWrapper() {
if (StreamProxy.ENABLED) { if (StreamProxy.ENABLED) {
if (StreamProxy.isCached(this.originalUri)) { if (StreamProxy.isCached(this.originalUri)) {
percentAvailable = 100 percentAvailable = 100
if (originalUri != null && metadata != null) {
PlayerWrapper.storeOffline(originalUri!!, metadata!!)
}
} }
else { else {
StreamProxy.registerCacheListener(this.cacheListener, this.originalUri) StreamProxy.registerCacheListener(this.cacheListener, this.originalUri)
@ -258,6 +276,12 @@ class ExoPlayerWrapper : PlayerWrapper() {
private val cacheListener = { _: File, _: String, percent: Int -> private val cacheListener = { _: File, _: String, percent: Int ->
//Log.e("CLCLCL", String.format("%d", percent)); //Log.e("CLCLCL", String.format("%d", percent));
percentAvailable = percent percentAvailable = percent
if (percentAvailable >= 100) {
if (originalUri != null && metadata != null) {
PlayerWrapper.storeOffline(originalUri!!, metadata!!)
}
}
} }
private var eventListener = object : ExoPlayer.EventListener { private var eventListener = object : ExoPlayer.EventListener {
@ -293,7 +317,8 @@ class ExoPlayerWrapper : PlayerWrapper() {
if (!prefetch) { if (!prefetch) {
player.playWhenReady = true player.playWhenReady = true
state = State.Playing state = State.Playing
} else { }
else {
state = State.Paused state = State.Paused
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,8 +1,14 @@
package io.casey.musikcube.remote.playback package io.casey.musikcube.remote.playback
import io.casey.musikcube.remote.Application
import io.casey.musikcube.remote.offline.OfflineTrack
import java.util.HashSet import java.util.HashSet
import io.casey.musikcube.remote.util.Preconditions 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 { abstract class PlayerWrapper {
private enum class Type { private enum class Type {
@ -38,8 +44,8 @@ abstract class PlayerWrapper {
} }
} }
abstract fun play(uri: String) abstract fun play(uri: String, metadata: JSONObject)
abstract fun prefetch(uri: String) abstract fun prefetch(uri: String, metadata: JSONObject)
abstract fun pause() abstract fun pause()
abstract fun resume() abstract fun resume()
abstract fun updateVolume() abstract fun updateVolume()
@ -69,6 +75,19 @@ abstract class PlayerWrapper {
private var globalMuted = false private var globalMuted = false
private var preDuckGlobalVolume = DUCK_NONE 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() { fun duck() {
Preconditions.throwIfNotOnMainThread() Preconditions.throwIfNotOnMainThread()

View File

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

View File

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

View File

@ -71,17 +71,17 @@ public class SystemService extends Service {
private SimpleTarget<Bitmap> albumArtRequest; private SimpleTarget<Bitmap> albumArtRequest;
public static void wakeup() { 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)); c.startService(new Intent(c, SystemService.class).setAction(ACTION_WAKE_UP));
} }
public static void shutdown() { 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)); c.startService(new Intent(c, SystemService.class).setAction(ACTION_SHUT_DOWN));
} }
public static void sleep() { 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)); 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.SocketMessage;
import io.casey.musikcube.remote.websocket.WebSocketService; import io.casey.musikcube.remote.websocket.WebSocketService;
import static io.casey.musikcube.remote.ui.model.TrackListSlidingWindow.QueryFactory;
public class PlayQueueActivity extends WebSocketActivityBase { public class PlayQueueActivity extends WebSocketActivityBase {
private static String EXTRA_PLAYING_INDEX = "extra_playing_index"; private static String EXTRA_PLAYING_INDEX = "extra_playing_index";
@ -35,6 +37,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
private TrackListSlidingWindow<JSONObject> tracks; private TrackListSlidingWindow<JSONObject> tracks;
private PlaybackService playback; private PlaybackService playback;
private Adapter adapter; private Adapter adapter;
private boolean offlineQueue;
@Override @Override
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
@ -54,11 +57,16 @@ public class PlayQueueActivity extends WebSocketActivityBase {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
Views.setupDefaultRecyclerView(this, recyclerView, fastScroller, adapter); 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<>( this.tracks = new TrackListSlidingWindow<>(
recyclerView, recyclerView,
fastScroller, fastScroller,
this.wss, this.wss,
this.playback.getPlaylistQueryFactory(), queryFactory,
(JSONObject obj) -> obj); (JSONObject obj) -> obj);
this.tracks.setInitialPosition( this.tracks.setInitialPosition(
@ -110,7 +118,7 @@ public class PlayQueueActivity extends WebSocketActivityBase {
private final WebSocketService.Client webSocketClient = new WebSocketService.Client() { private final WebSocketService.Client webSocketClient = new WebSocketService.Client() {
@Override @Override
public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) { public void onStateChanged(WebSocketService.State newState, WebSocketService.State oldState) {
if (newState == WebSocketService.State.Connected) { if (newState == WebSocketService.State.Connected || offlineQueue) {
tracks.requery(); tracks.requery();
} }
} }
@ -147,10 +155,13 @@ public class PlayQueueActivity extends WebSocketActivityBase {
subtitle.setText("-"); subtitle.setText("-");
} }
else { else {
long playingId = playback.getTrackLong(Messages.Key.ID, -1); final String entryExternalId = entry
long entryId = entry.optLong(Messages.Key.ID, -1); .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; titleColor = R.color.theme_green;
subtitleColor = R.color.theme_yellow; subtitleColor = R.color.theme_yellow;
} }

View File

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

View File

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

View File

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

View File

@ -286,6 +286,16 @@ public class SocketMessage {
return builder; 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) { public Builder addOption(final String key, final Object value) {
try { try {
options.put(key, value); options.put(key, value);

View File

@ -73,6 +73,14 @@ public class WebSocketService {
boolean check(T value); boolean check(T value);
} }
public interface Interceptor {
boolean process(SocketMessage message, Responder responder);
}
public interface Responder {
void respond(SocketMessage response);
}
public enum State { public enum State {
Connecting, Connecting,
Connected, Connected,
@ -169,6 +177,7 @@ public class WebSocketService {
private boolean autoReconnect = false; private boolean autoReconnect = false;
private NetworkChangedReceiver networkChanged = new NetworkChangedReceiver(); private NetworkChangedReceiver networkChanged = new NetworkChangedReceiver();
private ConnectThread thread; private ConnectThread thread;
private Set<Interceptor> interceptors = new HashSet<>();
public static synchronized WebSocketService getInstance(final Context context) { public static synchronized WebSocketService getInstance(final Context context) {
if (INSTANCE == null) { if (INSTANCE == null) {
@ -184,6 +193,16 @@ public class WebSocketService {
handler.sendEmptyMessageDelayed(MESSAGE_REMOVE_OLD_CALLBACKS, CALLBACK_TIMEOUT_MILLIS); 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) { public void addClient(Client client) {
Preconditions.throwIfNotOnMainThread(); Preconditions.throwIfNotOnMainThread();
@ -267,35 +286,47 @@ public class WebSocketService {
public long send(final SocketMessage message, Client client, MessageResultCallback callback) { public long send(final SocketMessage message, Client client, MessageResultCallback callback) {
Preconditions.throwIfNotOnMainThread(); Preconditions.throwIfNotOnMainThread();
if (this.socket != null) { boolean intercepted = false;
/* 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()) {
this.disconnect(true);
}
else {
long id = NEXT_ID.incrementAndGet();
if (callback != null) { for (final Interceptor i : interceptors) {
if (!clients.contains(client) && client != INTERNAL_CLIENT) { if (i.process(message, responder)) {
throw new IllegalArgumentException("client is not registered"); intercepted = true;
}
final MessageResultDescriptor mrd = new MessageResultDescriptor();
mrd.id = id;
mrd.enqueueTime = System.currentTimeMillis();
mrd.client = client;
mrd.callback = callback;
messageCallbacks.put(message.getId(), mrd);
}
this.socket.sendText(message.toString());
return id;
} }
} }
return -1; if (!intercepted) {
/* it seems that sometimes the socket dies, but the onDisconnected() event is not
raised. unclear if this is our bug or a bug in the library. disconnect and trigger
a reconnect until we can find a better root cause. this is very difficult to repro */
if (this.socket != null && !this.socket.isOpen()) {
this.disconnect(true);
return -1;
}
else if (this.socket == null) {
return -1;
}
}
final long id = NEXT_ID.incrementAndGet();
if (callback != null) {
if (!clients.contains(client) && client != INTERNAL_CLIENT) {
throw new IllegalArgumentException("client is not registered");
}
final MessageResultDescriptor mrd = new MessageResultDescriptor();
mrd.id = id;
mrd.enqueueTime = System.currentTimeMillis();
mrd.client = client;
mrd.callback = callback;
messageCallbacks.put(message.getId(), mrd);
}
if (!intercepted) {
this.socket.sendText(message.toString());
}
return id;
} }
public Observable<SocketMessage> send(final SocketMessage message, Client client) { public Observable<SocketMessage> send(final SocketMessage message, Client client) {
@ -305,39 +336,52 @@ public class WebSocketService {
try { try {
Preconditions.throwIfNotOnMainThread(); 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 /* 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 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 */ 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); disconnect(true);
throw new Exception("socket disconnected"); throw new Exception("socket disconnected");
} }
else { else if (socket == null) {
if (!clients.contains(client) && client != INTERNAL_CLIENT) { throw new Exception("socket not connected");
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();
};
mrd.error = () -> {
final Exception ex = new Exception();
ex.fillInStackTrace();
emitter.onError(ex);
};
messageCallbacks.put(message.getId(), mrd);
socket.sendText(message.toString());
} }
} }
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();
};
mrd.error = () -> {
final Exception ex = new Exception();
ex.fillInStackTrace();
emitter.onError(ex);
};
messageCallbacks.put(message.getId(), mrd);
if (!intercepted) {
socket.sendText(message.toString());
}
} }
catch (Exception ex) { catch (Exception ex) {
emitter.onError(ex); emitter.onError(ex);
@ -486,6 +530,14 @@ public class WebSocketService {
private Runnable autoDisconnectRunnable = () -> disconnect(); 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() { private WebSocketAdapter webSocketAdapter = new WebSocketAdapter() {
@Override @Override
public void onTextMessage(WebSocket websocket, String text) throws Exception { public void onTextMessage(WebSocket websocket, String text) throws Exception {

View File

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

View File

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