From 5b356c499192b35764ec3de21f5fbada0dfa478d Mon Sep 17 00:00:00 2001 From: casey langen Date: Sun, 21 Jan 2018 23:38:45 -0800 Subject: [PATCH] Refactor and additions to support play queue snapshotting, and the ability to transfer playback context seamlessly between server and device. --- .../remote/injection/DataComponent.kt | 7 +- .../service/playback/IPlaybackService.kt | 7 +- .../remote/service/playback/QueryContext.kt | 25 +++ .../impl/player/GaplessExoPlayerWrapper.kt | 24 +- .../impl/remote/RemotePlaybackService.kt | 57 ++++- .../streaming/StreamingPlaybackService.kt | 147 +++++++----- .../remote/service/websocket/Messages.kt | 89 ++++---- .../service/websocket/model/IDataProvider.kt | 12 +- .../websocket/model/ITrackListQueryFactory.kt | 9 + .../service/websocket/model/PlayQueueType.kt | 6 + .../remote/IdListTrackListQueryFactory.kt | 48 ++++ .../model/impl/remote/RemoteDataProvider.kt | 42 +++- .../remote/ui/home/activity/MainActivity.kt | 36 ++- .../remote/ui/home/view/MainMetadataView.kt | 1 + .../playqueue/activity/PlayQueueActivity.kt | 11 +- .../ui/playqueue/adapter/PlayQueueAdapter.kt | 4 +- .../ui/shared/model/BaseSlidingWindow.kt | 86 +++++++ .../ui/shared/model/DefaultSlidingWindow.kt | 132 +++++++++++ .../shared/model/ITrackListSlidingWindow.kt | 17 ++ .../ui/shared/model/TrackListSlidingWindow.kt | 212 ------------------ .../ui/tracks/activity/TrackListActivity.kt | 17 +- .../ui/tracks/adapter/TrackListAdapter.kt | 4 +- 22 files changed, 618 insertions(+), 375 deletions(-) create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/QueryContext.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/ITrackListQueryFactory.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/PlayQueueType.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/IdListTrackListQueryFactory.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/BaseSlidingWindow.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/DefaultSlidingWindow.kt create mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/ITrackListSlidingWindow.kt delete mode 100644 src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/TrackListSlidingWindow.kt diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/DataComponent.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/DataComponent.kt index 2b6ef198b..8ca0afdaf 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/DataComponent.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/injection/DataComponent.kt @@ -3,13 +3,12 @@ package io.casey.musikcube.remote.injection import android.content.Context import dagger.Component import io.casey.musikcube.remote.service.playback.impl.streaming.db.OfflineDb +import io.casey.musikcube.remote.service.websocket.model.impl.remote.IdListTrackListQueryFactory @DataScope -@Component( - dependencies = arrayOf(AppComponent::class), - modules = arrayOf(DataModule::class)) +@Component(dependencies = arrayOf(AppComponent::class)) interface DataComponent { fun inject(db: OfflineDb) - + fun inject(slidingWindow: IdListTrackListQueryFactory) fun context(): Context /* via AppComponent */ } \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/IPlaybackService.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/IPlaybackService.kt index 14f1477a8..ccdc3943f 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/IPlaybackService.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/IPlaybackService.kt @@ -1,7 +1,7 @@ package io.casey.musikcube.remote.service.playback import io.casey.musikcube.remote.service.websocket.model.ITrack -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory interface IPlaybackService { fun connect(listener: () -> Unit) @@ -12,6 +12,8 @@ interface IPlaybackService { fun play(category: String, categoryId: Long, index: Int = 0, filter: String = "") fun playAt(index: Int) + fun playFrom(service: IPlaybackService) + fun pauseOrResume() fun pause() fun resume() @@ -45,7 +47,8 @@ interface IPlaybackService { fun toggleRepeatMode() val repeatMode: RepeatMode - val playlistQueryFactory: TrackListSlidingWindow.QueryFactory + val playlistQueryFactory: ITrackListQueryFactory + val queryContext: QueryContext? val playingTrack: ITrack } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/QueryContext.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/QueryContext.kt new file mode 100644 index 000000000..3affb3c26 --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/QueryContext.kt @@ -0,0 +1,25 @@ +package io.casey.musikcube.remote.service.playback + +import io.casey.musikcube.remote.service.websocket.Messages + +class QueryContext { + val category: String? + val categoryId: Long + val filter: String + val type: Messages.Request? + + constructor(type: Messages.Request? = null) + : this("", type) + + constructor(filter: String, type: Messages.Request? = null) + : this("", -1L, filter, type) + + constructor(category: String, categoryId: Long, filter: String, type: Messages.Request? = null) { + this.category = category + this.categoryId = categoryId + this.filter = filter + this.type = type + } + + fun hasCategory(): Boolean = (category?.isNotBlank() ?: false && (categoryId >= 0)) +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/player/GaplessExoPlayerWrapper.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/player/GaplessExoPlayerWrapper.kt index 56c2c80fd..19f6417bb 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/player/GaplessExoPlayerWrapper.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/player/GaplessExoPlayerWrapper.kt @@ -15,6 +15,7 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.trackselection.TrackSelectionArray import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory import com.google.android.exoplayer2.util.Util import io.casey.musikcube.remote.Application @@ -25,9 +26,7 @@ import io.casey.musikcube.remote.util.Preconditions import java.io.File class GaplessExoPlayerWrapper : PlayerWrapper() { - private var sourceFactory: DataSource.Factory = DefaultHttpDataSourceFactory( - Util.getUserAgent(context, "musikdroid"), null, TIMEOUT, TIMEOUT, true) - + private var sourceFactory: DataSource.Factory private val extractorsFactory = DefaultExtractorsFactory() private var source: MediaSource? = null private var metadata: ITrack? = null @@ -40,6 +39,12 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { private var initialOffsetMs: Int = 0 init { + val userAgent = Util.getUserAgent(context, "musikdroid") + + val httpFactory: DataSource.Factory = DefaultHttpDataSourceFactory( + userAgent, null, TIMEOUT, TIMEOUT, true) + + this.sourceFactory = DefaultDataSourceFactory(context, null, httpFactory) this.transcoding = prefs.getInt(Prefs.Key.TRANSCODER_BITRATE_INDEX, 0) != 0 } @@ -58,7 +63,7 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { this.source = ExtractorMediaSource(Uri.parse(proxyUri), sourceFactory, extractorsFactory, null, null) - addPlayer(this, this.source!!, playNow = true) + addPlayer(this, this.source!!) state = State.Preparing } @@ -126,7 +131,7 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { if (gaplessPlayer?.playbackState != ExoPlayer.STATE_IDLE) { if (gaplessPlayer?.isCurrentWindowSeekable == true) { var offset = millis.toLong() - val isInitialSeek = initialOffsetMs > 0 && (position == initialOffsetMs) + val isInitialSeek = initialOffsetMs > 0 && (millis == initialOffsetMs) /* if we're transcoding we don't want to seek arbitrarily because it may put a lot of pressure on the backend. just allow seeking up to what we currently @@ -242,6 +247,9 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { gaplessPlayer?.seekTo(lastPosition) lastPosition = -1 } + else if (initialOffsetMs > 0) { + position = initialOffsetMs + } if (!prefetch) { gaplessPlayer?.playWhenReady = true @@ -334,7 +342,7 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { } } - private fun addPlayer(wrapper: GaplessExoPlayerWrapper, source: MediaSource, playNow: Boolean = false) { + private fun addPlayer(wrapper: GaplessExoPlayerWrapper, source: MediaSource) { addActivePlayer(wrapper) if (all.size == 0) { @@ -344,10 +352,6 @@ class GaplessExoPlayerWrapper : PlayerWrapper() { dcms.addMediaSource(source) all.add(wrapper) - if (playNow) { - gaplessPlayer?.playWhenReady = true - } - if (dcms.size == 1) { gaplessPlayer?.prepare(dcms) } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/remote/RemotePlaybackService.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/remote/RemotePlaybackService.kt index 258de31a4..ac5bc1de7 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/remote/RemotePlaybackService.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/remote/RemotePlaybackService.kt @@ -5,6 +5,7 @@ import io.casey.musikcube.remote.Application import io.casey.musikcube.remote.injection.DaggerServiceComponent import io.casey.musikcube.remote.service.playback.IPlaybackService import io.casey.musikcube.remote.service.playback.PlaybackState +import io.casey.musikcube.remote.service.playback.QueryContext import io.casey.musikcube.remote.service.playback.RepeatMode import io.casey.musikcube.remote.service.websocket.Messages import io.casey.musikcube.remote.service.websocket.SocketMessage @@ -12,7 +13,7 @@ import io.casey.musikcube.remote.service.websocket.WebSocketService import io.casey.musikcube.remote.service.websocket.model.IDataProvider import io.casey.musikcube.remote.service.websocket.model.ITrack import io.casey.musikcube.remote.service.websocket.model.impl.remote.RemoteTrack -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory import io.reactivex.Observable import org.json.JSONObject import java.util.* @@ -21,16 +22,16 @@ import javax.inject.Inject class RemotePlaybackService : IPlaybackService { private interface Key { companion object { - val STATE = "state" - val REPEAT_MODE = "repeat_mode" - val VOLUME = "volume" - val SHUFFLED = "shuffled" - val MUTED = "muted" - val PLAY_QUEUE_COUNT = "track_count" - val PLAY_QUEUE_POSITION = "play_queue_position" - val PLAYING_DURATION = "playing_duration" - val PLAYING_CURRENT_TIME = "playing_current_time" - val PLAYING_TRACK = "playing_track" + const val STATE = "state" + const val REPEAT_MODE = "repeat_mode" + const val VOLUME = "volume" + const val SHUFFLED = "shuffled" + const val MUTED = "muted" + const val PLAY_QUEUE_COUNT = "track_count" + const val PLAY_QUEUE_POSITION = "play_queue_position" + const val PLAYING_DURATION = "playing_duration" + const val PLAYING_CURRENT_TIME = "playing_current_time" + const val PLAYING_TRACK = "playing_track" } } @@ -175,6 +176,35 @@ class RemotePlaybackService : IPlaybackService { .build()) } + override fun playFrom(service: IPlaybackService) { + service.queryContext?.let {qc -> + val time = service.currentTime + val index = service.queuePosition + + when (qc.type) { + Messages.Request.PlaySnapshotTracks -> { + wss.send(SocketMessage.Builder + .request(Messages.Request.PlaySnapshotTracks) + .addOption(Messages.Key.TIME, time) + .addOption(Messages.Key.INDEX, index) + .build()) + } + Messages.Request.QueryTracks, + Messages.Request.QueryTracksByCategory -> { + wss.send(SocketMessage.Builder + .request(Messages.Request.PlayTracksByCategory) + .addOption(Messages.Key.CATEGORY, qc.category) + .addOption(Messages.Key.ID, qc.categoryId) + .addOption(Messages.Key.FILTER, qc.filter) + .addOption(Messages.Key.TIME, time) + .addOption(Messages.Key.INDEX, index) + .build()) + } + else -> { } + } + } + } + override fun prev() { wss.send(SocketMessage.Builder.request(Messages.Request.Previous).build()) } @@ -371,7 +401,10 @@ class RemotePlaybackService : IPlaybackService { } } - override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() { + override val queryContext: QueryContext? + get() = QueryContext(Messages.Request.QueryPlayQueueTracks) + + override val playlistQueryFactory: ITrackListQueryFactory = object : ITrackListQueryFactory { override fun count(): Observable = dataProvider.getPlayQueueTracksCount() override fun page(offset: Int, limit: Int): Observable> = dataProvider.getPlayQueueTracks(limit, offset) override fun offline(): Boolean = false diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamingPlaybackService.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamingPlaybackService.kt index dcaaac5bc..359fbd7e9 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamingPlaybackService.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/playback/impl/streaming/StreamingPlaybackService.kt @@ -12,23 +12,20 @@ import android.util.Log import io.casey.musikcube.remote.Application import io.casey.musikcube.remote.R import io.casey.musikcube.remote.injection.DaggerServiceComponent -import io.casey.musikcube.remote.service.playback.IPlaybackService -import io.casey.musikcube.remote.service.playback.PlaybackState -import io.casey.musikcube.remote.service.playback.PlayerWrapper -import io.casey.musikcube.remote.service.playback.RepeatMode +import io.casey.musikcube.remote.service.playback.* import io.casey.musikcube.remote.service.system.SystemService import io.casey.musikcube.remote.service.websocket.Messages import io.casey.musikcube.remote.service.websocket.model.IDataProvider import io.casey.musikcube.remote.service.websocket.model.ITrack +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory +import io.casey.musikcube.remote.service.websocket.model.PlayQueueType import io.casey.musikcube.remote.service.websocket.model.impl.remote.RemoteTrack import io.casey.musikcube.remote.ui.settings.constants.Prefs -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow import io.casey.musikcube.remote.util.Strings import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.subscribeBy import org.json.JSONObject -import java.net.URLEncoder import java.util.* import javax.inject.Inject @@ -37,7 +34,6 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE) private val listeners = HashSet<() -> Unit>() - private var params: QueueParams? = null private var playContext = PlaybackContext() private var audioManager: AudioManager? = null private var lastSystemVolume: Int = 0 @@ -120,24 +116,6 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } } - private class QueueParams { - internal val category: String? - internal val categoryId: Long - internal val filter: String - - constructor(filter: String) { - this.filter = filter - this.categoryId = -1 - this.category = null - } - - constructor(category: String, categoryId: Long, filter: String) { - this.category = category - this.categoryId = categoryId - this.filter = filter - } - } - init { DaggerServiceComponent.builder() .appComponent(Application.appComponent) @@ -172,22 +150,62 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { override fun playAll(index: Int, filter: String) { if (requestAudioFocus()) { trackMetadataCache.clear() - loadQueueAndPlay(QueueParams(filter), index) + resetPlayContextAndQueryFactory() + val type = Messages.Request.QueryTracks + loadQueueAndPlay(QueryContext(filter, type), index) } } override fun play(category: String, categoryId: Long, index: Int, filter: String) { if (requestAudioFocus()) { trackMetadataCache.clear() - loadQueueAndPlay(QueueParams(category, categoryId, filter), index) + resetPlayContextAndQueryFactory() + val type = Messages.Request.QueryTracksByCategory + loadQueueAndPlay(QueryContext(category, categoryId, filter, type), index) } } override fun playAt(index: Int) { - if (params != null) { + if (queryContext != null) { if (requestAudioFocus()) { - playContext.stopPlaybackAndReset() - loadQueueAndPlay(params!!, index) + resetPlayContextAndQueryFactory() + loadQueueAndPlay(queryContext!!, index) + } + } + } + + override fun playFrom(service: IPlaybackService) { + /* we only support switching from a play queue context! */ + if (service.queryContext?.type == Messages.Request.QueryPlayQueueTracks) { + val dummyListener: (() -> Unit) = { } + connect(dummyListener) + service.queryContext?.let { _ -> + dataProvider.snapshotPlayQueue().subscribeBy( + onNext = { + disconnect(dummyListener) + + resetPlayContextAndQueryFactory() + val index = service.queuePosition + val offsetMs = (service.currentTime * 1000).toInt() + val context = QueryContext(Messages.Request.PlaySnapshotTracks) + val type = PlayQueueType.Snapshot + + snapshotQueryFactory = object: ITrackListQueryFactory { + override fun count(): Observable? = + dataProvider.getPlayQueueTracksCount(type) + + override fun page(offset: Int, limit: Int): Observable>? = + dataProvider.getPlayQueueTracks(limit, offset, type) + + override fun offline(): Boolean = false + } + + service.pause() + loadQueueAndPlay(context, index, offsetMs) + }, + onError = { + disconnect(dummyListener) + }) } } } @@ -377,6 +395,11 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { return 0.0 } + private fun resetPlayContextAndQueryFactory() { + playContext.stopPlaybackAndReset() + snapshotQueryFactory = null + } + private fun pauseTransient() { if (state !== PlaybackState.Paused) { pausedByTransientLoss = true @@ -439,7 +462,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { private fun moveToPrevTrack() { if (playContext.queueCount > 0) { - loadQueueAndPlay(params!!, resolvePrevIndex( + loadQueueAndPlay(queryContext!!, resolvePrevIndex( playContext.currentIndex, playContext.queueCount)) } } @@ -455,7 +478,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { manually (this will automatically load the current and next tracks */ val next = resolveNextIndex(index, playContext.queueCount, userInitiated) if (next >= 0) { - loadQueueAndPlay(params!!, next) + loadQueueAndPlay(queryContext!!, next) } else { stop() @@ -642,7 +665,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { private fun prefetchNextTrackMetadata() { if (playContext.nextMetadata == null) { - val originalParams = params + val originalParams = queryContext val nextIndex = resolveNextIndex(playContext.currentIndex, playContext.queueCount, false) if (trackMetadataCache.containsKey(nextIndex)) { @@ -660,7 +683,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { .subscribeOn(AndroidSchedulers.mainThread()) .subscribe( { track -> - if (originalParams === params && playContext.currentIndex == currentIndex) { + if (originalParams === queryContext && playContext.currentIndex == currentIndex) { if (playContext.nextMetadata == null) { playContext.nextIndex = nextIndex playContext.nextMetadata = track.firstOrNull() @@ -676,7 +699,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } } - private fun loadQueueAndPlay(newParams: QueueParams, startIndex: Int) { + private fun loadQueueAndPlay(newParams: QueryContext, startIndex: Int, offsetMs: Int = 0) { state = PlaybackState.Buffering cancelScheduledPausedSleep() @@ -689,7 +712,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { playContext = newPlayContext playContext.currentIndex = startIndex - params = newParams + queryContext = newParams val countMessage = playlistQueryFactory.count() ?: return @@ -711,7 +734,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { state = PlaybackState.Stopped }, onComplete = { - if (this.params === newParams && playContext === newPlayContext) { + if (this.queryContext === newParams && playContext === newPlayContext) { notifyEventListeners() val uri = getUri(playContext.currentMetadata) @@ -719,11 +742,11 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { if (uri != null) { playContext.currentPlayer = PlayerWrapper.newInstance(prefs) playContext.currentPlayer?.setOnStateChangedListener(onCurrentPlayerStateChanged) - playContext.currentPlayer?.play(uri, playContext.currentMetadata!!) + playContext.currentPlayer?.play(uri, playContext.currentMetadata!!, offsetMs) } } else { - Log.d(TAG, "onComplete fired, but params/context changed. discarding!") + Log.d(TAG, "onComplete fired, but queryContext/context changed. discarding!") } }) } @@ -738,7 +761,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } private fun precacheTrackMetadata(start: Int, count: Int) { - val originalParams = params + val originalParams = queryContext val query = playlistQueryFactory.page(start, count) if (query != null) { @@ -746,7 +769,7 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { .subscribeOn(AndroidSchedulers.mainThread()) .subscribe( { response -> - if (originalParams === this.params) { + if (originalParams === this.queryContext) { response.forEachIndexed { i, track -> trackMetadataCache.put(start + i, track) } @@ -758,11 +781,16 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } } - override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() { + override var queryContext: QueryContext? = null + private set(value) { field = value } + + var snapshotQueryFactory: ITrackListQueryFactory? = null + + val defaultQueryFactory: ITrackListQueryFactory = object : ITrackListQueryFactory { override fun count(): Observable? { - val params = params + val params = queryContext if (params != null) { - if (Strings.notEmpty(params.category) && (params.categoryId >= 0)) { + if (params.hasCategory()) { return dataProvider.getTrackCountByCategory( params.category ?: "", params.categoryId, params.filter) } @@ -774,9 +802,9 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } override fun page(offset: Int, limit: Int): Observable>? { - val params = params + val params = queryContext if (params != null) { - if (Strings.notEmpty(params.category) && (params.categoryId >= 0)) { + if (params.hasCategory()) { return dataProvider.getTracksByCategory( params.category ?: "", params.categoryId, limit, offset, params.filter) } @@ -788,10 +816,21 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } override fun offline(): Boolean { - return params?.category == Messages.Category.OFFLINE + return queryContext?.category == Messages.Category.OFFLINE } } + override val playlistQueryFactory: ITrackListQueryFactory = object : ITrackListQueryFactory { + override fun count(): Observable? = + snapshotQueryFactory?.count() ?: defaultQueryFactory.count() + + override fun page(offset: Int, limit: Int): Observable>? = + snapshotQueryFactory?.page(offset, limit) ?: defaultQueryFactory.page(offset, limit) + + override fun offline(): Boolean = + snapshotQueryFactory?.offline() ?: defaultQueryFactory.offline() + } + init { this.audioManager = Application.instance?.getSystemService(Context.AUDIO_SERVICE) as AudioManager this.lastSystemVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0 @@ -862,12 +901,12 @@ class StreamingPlaybackService(context: Context) : IPlaybackService { } companion object { - private val TAG = "StreamingPlayback" - private val REPEAT_MODE_PREF = "streaming_playback_repeat_mode" - private val PREV_TRACK_GRACE_PERIOD_MILLIS = 3500 - private val MAX_TRACK_METADATA_CACHE_SIZE = 50 - private val PRECACHE_METADATA_SIZE = 10 - private val PAUSED_SERVICE_SLEEP_DELAY_MS = 1000 * 60 * 5 /* 5 minutes */ - private val DATA_PROVIDER_DISCONNECT_DELAY_MS = 5000 + private const val TAG = "StreamingPlayback" + private const val REPEAT_MODE_PREF = "streaming_playback_repeat_mode" + private const val PREV_TRACK_GRACE_PERIOD_MILLIS = 3500 + private const val MAX_TRACK_METADATA_CACHE_SIZE = 50 + private const val PRECACHE_METADATA_SIZE = 10 + private const val PAUSED_SERVICE_SLEEP_DELAY_MS = 1000 * 60 * 5 /* 5 minutes */ + private const val DATA_PROVIDER_DISCONNECT_DELAY_MS = 5000 } } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/Messages.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/Messages.kt index 9b38e134a..2e529917a 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/Messages.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/Messages.kt @@ -20,6 +20,7 @@ class Messages { PlayAllTracks("play_all_tracks"), PlayTracks("play_tracks"), PlayTracksByCategory("play_tracks_by_category"), + PlaySnapshotTracks("play_snapshot_tracks"), QueryTracks("query_tracks"), ListCategories("list_categories"), QueryTracksByCategory("query_tracks_by_category"), @@ -38,7 +39,8 @@ class Messages { SetGainSettings("set_gain_settings"), RunIndexer("run_indexer"), GetTransportType("get_transport_type"), - SetTransportType("set_transport_type"); + SetTransportType("set_transport_type"), + SnapshotPlayQueue("snapshot_play_queue"); override fun toString(): String = rawValue fun matches(name: String): Boolean = (rawValue == name) @@ -76,58 +78,59 @@ class Messages { class Key { companion object { - val CATEGORY = "category" - val CATEGORY_ID = "category_id" - val DATA = "data" - val ALL = "all" - val SELECTED = "selected" - val ID = "id" - val COUNT = "count" - val COUNT_ONLY = "count_only" - val IDS_ONLY = "ids_only" - val OFFSET = "offset" - val LIMIT = "limit" - val INDEX = "index" - val DELTA = "delta" - val POSITION = "position" - val VALUE = "value" - val FILTER = "filter" - val RELATIVE = "relative" - val PLAYING_CURRENT_TIME = "playing_current_time" - val PLAYLIST_ID = "playlist_id" - val PLAYLIST_NAME = "playlist_name" - val PREDICATE_CATEGORY = "predicate_category" - val PREDICATE_ID = "predicate_id" - val SUBQUERY = "subquery" - val TYPE = "type" - val OPTIONS = "options" - val SUCCESS = "success" - val EXTERNAL_IDS = "external_ids" - val SORT_ORDERS = "sort_orders" - val DRIVER_NAME = "driver_name" - val DEVICE_ID = "device_id" - val REPLAYGAIN_MODE = "replaygain_mode" - val PREAMP_GAIN = "preamp_gain" + const val CATEGORY = "category" + const val CATEGORY_ID = "category_id" + const val DATA = "data" + const val ALL = "all" + const val SELECTED = "selected" + const val ID = "id" + const val COUNT = "count" + const val COUNT_ONLY = "count_only" + const val IDS_ONLY = "ids_only" + const val OFFSET = "offset" + const val LIMIT = "limit" + const val INDEX = "index" + const val DELTA = "delta" + const val POSITION = "position" + const val VALUE = "value" + const val FILTER = "filter" + const val RELATIVE = "relative" + const val PLAYING_CURRENT_TIME = "playing_current_time" + const val PLAYLIST_ID = "playlist_id" + const val PLAYLIST_NAME = "playlist_name" + const val PREDICATE_CATEGORY = "predicate_category" + const val PREDICATE_ID = "predicate_id" + const val SUBQUERY = "subquery" + const val TYPE = "type" + const val TIME = "time" + const val OPTIONS = "options" + const val SUCCESS = "success" + const val EXTERNAL_IDS = "external_ids" + const val SORT_ORDERS = "sort_orders" + const val DRIVER_NAME = "driver_name" + const val DEVICE_ID = "device_id" + const val REPLAYGAIN_MODE = "replaygain_mode" + const val PREAMP_GAIN = "preamp_gain" } } interface Value { companion object { - val UP = "up" - val DOWN = "down" - val REINDEX = "reindex" - val REBUILD = "rebuild" + const val UP = "up" + const val DOWN = "down" + const val REINDEX = "reindex" + const val REBUILD = "rebuild" } } interface Category { companion object { - val OFFLINE = "offline" - val ALBUM = "album" - val ARTIST = "artist" - val ALBUM_ARTIST = "album_artist" - val GENRE = "genre" - val PLAYLISTS = "playlists" + const val OFFLINE = "offline" + const val ALBUM = "album" + const val ARTIST = "artist" + const val ALBUM_ARTIST = "album_artist" + const val GENRE = "genre" + const val PLAYLISTS = "playlists" } } } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/IDataProvider.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/IDataProvider.kt index 384f3320e..159d15dee 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/IDataProvider.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/IDataProvider.kt @@ -1,5 +1,6 @@ package io.casey.musikcube.remote.service.websocket.model +import io.casey.musikcube.remote.service.websocket.Messages import io.reactivex.Observable interface IDataProvider { @@ -28,9 +29,12 @@ interface IDataProvider { fun getTracksByCategory(category: String, id: Long, filter: String = ""): Observable> fun getTracksByCategory(category: String, id: Long, limit: Int, offset: Int, filter: String = ""): Observable> - fun getPlayQueueTracksCount(filter: String = ""): Observable - fun getPlayQueueTracks(filter: String = ""): Observable> - fun getPlayQueueTracks(limit: Int, offset: Int, filter: String = ""): Observable> + fun getPlayQueueTracksCount(type: PlayQueueType = PlayQueueType.Live): Observable + fun getPlayQueueTracks(type: PlayQueueType = PlayQueueType.Live): Observable> + fun getPlayQueueTracks(limit: Int, offset: Int, type: PlayQueueType = PlayQueueType.Live): Observable> + fun getPlayQueueTrackIds(limit: Int, offset: Int, type: PlayQueueType = PlayQueueType.Live): Observable> + fun getPlayQueueTrackIds(type: PlayQueueType = PlayQueueType.Live): Observable> + fun snapshotPlayQueue(): Observable fun getPlaylists(): Observable> @@ -49,7 +53,7 @@ interface IDataProvider { fun removeTracksFromPlaylist(playlistId: Long, externalIds: List, sortOrders: List): Observable fun listOutputDrivers(): Observable - fun setDefaultOutputDriver(driverName: String, deviceId: String = "default"): Observable + fun setDefaultOutputDriver(driverName: String, deviceId: String = ""): Observable fun getGainSettings(): Observable fun updateGainSettings(replayGainMode: ReplayGainMode, preampGain: Float): Observable diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/ITrackListQueryFactory.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/ITrackListQueryFactory.kt new file mode 100644 index 000000000..6242dd9be --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/ITrackListQueryFactory.kt @@ -0,0 +1,9 @@ +package io.casey.musikcube.remote.service.websocket.model + +import io.reactivex.Observable + +interface ITrackListQueryFactory { + fun count(): Observable? + fun page(offset: Int, limit: Int): Observable>? + fun offline(): Boolean +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/PlayQueueType.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/PlayQueueType.kt new file mode 100644 index 000000000..5a857f708 --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/PlayQueueType.kt @@ -0,0 +1,6 @@ +package io.casey.musikcube.remote.service.websocket.model + +enum class PlayQueueType(val rawValue: String) { + Live("live"), + Snapshot("snapshot"); +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/IdListTrackListQueryFactory.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/IdListTrackListQueryFactory.kt new file mode 100644 index 000000000..f33c77e0f --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/IdListTrackListQueryFactory.kt @@ -0,0 +1,48 @@ +package io.casey.musikcube.remote.service.websocket.model.impl.remote + +import io.casey.musikcube.remote.Application +import io.casey.musikcube.remote.injection.DaggerDataComponent +import io.casey.musikcube.remote.service.websocket.model.IDataProvider +import io.casey.musikcube.remote.service.websocket.model.ITrack +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory +import io.reactivex.Observable +import org.json.JSONObject +import javax.inject.Inject + +class IdListTrackListQueryFactory(private val idList: List): ITrackListQueryFactory { + @Inject protected lateinit var dataProvider: IDataProvider + + init { + DaggerDataComponent.builder() + .appComponent(Application.appComponent) + .build().inject(this) + + dataProvider.attach() + } + + override fun page(offset: Int, limit: Int): Observable>? { + val window = mutableSetOf() + val max = Math.min(limit, idList.size) + + for (i in 0 until max) { + window.add(idList[offset + i]) + } + + val missing = RemoteTrack(JSONObject()) + return dataProvider.getTracks(window) + .flatMap{ it -> + val result = mutableListOf() + for (i in 0 until max) { + result.add(it[idList[offset + i]] ?: missing) + } + Observable.just(result) + } + } + + override fun count(): Observable = Observable.just(idList.size) + override fun offline(): Boolean = false + + fun destroy() { + dataProvider.destroy() + } +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/RemoteDataProvider.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/RemoteDataProvider.kt index 79e18ac2d..5e44bb149 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/RemoteDataProvider.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/service/websocket/model/impl/remote/RemoteDataProvider.kt @@ -163,11 +163,11 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider .observeOn(AndroidSchedulers.mainThread()) } - override fun getPlayQueueTracksCount(filter: String): Observable { + override fun getPlayQueueTracksCount(type: PlayQueueType): Observable { val message = SocketMessage.Builder .request(Messages.Request.QueryPlayQueueTracks) - .addOption(Messages.Key.FILTER, filter) .addOption(Messages.Key.COUNT_ONLY, true) + .addOption(Messages.Key.TYPE, type.rawValue) .build() return service.observe(message, client) @@ -175,13 +175,13 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider .observeOn(AndroidSchedulers.mainThread()) } - override fun getPlayQueueTracks(filter: String): Observable> = - getPlayQueueTracks(-1, -1, filter) + override fun getPlayQueueTracks(type: PlayQueueType): Observable> = + getPlayQueueTracks(-1, -1, type) - override fun getPlayQueueTracks(limit: Int, offset: Int, filter: String): Observable> { + override fun getPlayQueueTracks(limit: Int, offset: Int, type: PlayQueueType): Observable> { val builder = SocketMessage.Builder .request(Messages.Request.QueryPlayQueueTracks) - .addOption(Messages.Key.FILTER, filter) + .addOption(Messages.Key.TYPE, type.rawValue) if (limit > 0 && offset >= 0) { builder.addOption(Messages.Key.LIMIT, limit) @@ -194,6 +194,36 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider .observeOn(AndroidSchedulers.mainThread()) } + override fun getPlayQueueTrackIds(type: PlayQueueType): Observable> = + getPlayQueueTrackIds(-1, -1, type) + + override fun snapshotPlayQueue(): Observable { + val message = SocketMessage.Builder + .request(Messages.Request.SnapshotPlayQueue) + .build() + + return service.observe(message, client) + .flatMap { socketMessage -> isSuccessful(socketMessage) } + .observeOn(AndroidSchedulers.mainThread()) + } + + override fun getPlayQueueTrackIds(limit: Int, offset: Int, type: PlayQueueType): Observable> { + val builder = SocketMessage.Builder + .request(Messages.Request.QueryPlayQueueTracks) + .addOption(Messages.Key.IDS_ONLY, true) + .addOption(Messages.Key.TYPE, type.rawValue) + + if (limit > 0 && offset >= 0) { + builder.addOption(Messages.Key.LIMIT, limit) + builder.addOption(Messages.Key.OFFSET, offset) + } + + return service.observe(builder.build(), client) + .observeOn(Schedulers.computation()) + .flatMap> { socketMessage -> toStringList(socketMessage) } + .observeOn(AndroidSchedulers.mainThread()) + } + override fun getPlaylists(): Observable> { val message = SocketMessage.Builder .request(Messages.Request.QueryCategory) diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/activity/MainActivity.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/activity/MainActivity.kt index 164e3eaef..076729ca6 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/activity/MainActivity.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/activity/MainActivity.kt @@ -17,6 +17,7 @@ import android.widget.SeekBar import android.widget.TextView import com.wooplr.spotlight.SpotlightView import io.casey.musikcube.remote.R +import io.casey.musikcube.remote.service.playback.PlaybackServiceFactory import io.casey.musikcube.remote.service.playback.PlaybackState import io.casey.musikcube.remote.service.playback.RepeatMode import io.casey.musikcube.remote.service.websocket.Messages @@ -118,8 +119,11 @@ class MainActivity : BaseActivity() { menu.findItem(R.id.action_categories).isEnabled = connected menu.findItem(R.id.action_remote_manage).isEnabled = connected - menu.findItem(R.id.action_remote_toggle).setIcon( - if (streaming) R.drawable.ic_toolbar_streaming else R.drawable.ic_toolbar_remote) + val remoteToggle = menu.findItem(R.id.action_remote_toggle) + + remoteToggle.setIcon( + if (streaming) R.drawable.ic_toolbar_streaming + else R.drawable.ic_toolbar_remote) return super.onPrepareOptionsMenu(menu) } @@ -204,22 +208,32 @@ class MainActivity : BaseActivity() { Prefs.Key.STREAMING_PLAYBACK, Prefs.Default.STREAMING_PLAYBACK) - private fun togglePlaybackService() { - val streaming = isStreamingSelected + private fun togglePlaybackService(transfer: Boolean = false) { + val isStreaming = isStreamingSelected + prefs.edit().putBoolean(Prefs.Key.STREAMING_PLAYBACK, !isStreaming)?.apply() - if (streaming) { - playback.service.stop() - } - - prefs.edit().putBoolean(Prefs.Key.STREAMING_PLAYBACK, !streaming)?.apply() - - val messageId = if (streaming) + val messageId = if (isStreaming) R.string.snackbar_remote_enabled else R.string.snackbar_streaming_enabled showSnackbar(mainLayout, messageId) + if (transfer) { + val streaming = PlaybackServiceFactory.streaming(this) + val remote = PlaybackServiceFactory.remote(this) + + if (!isStreaming) { + streaming.playFrom(remote) + } else { + remote.playFrom(streaming) + } + } + + if (isStreaming) { + playback.service.stop() + } + playback.reload() invalidateOptionsMenu() diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/view/MainMetadataView.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/view/MainMetadataView.kt index dafcd6e2c..a83d0c60a 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/view/MainMetadataView.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/home/view/MainMetadataView.kt @@ -141,6 +141,7 @@ class MainMetadataView : FrameLayout { if (Strings.empty(artist) || Strings.empty(album)) { setMetadataDisplayMode(DisplayMode.NoArtwork) + loadedAlbumArtUrl = null } else { val newUrl = getAlbumArtUrl(playing, Size.Mega) ?: "" diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/activity/PlayQueueActivity.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/activity/PlayQueueActivity.kt index e3345025e..6d591c232 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/activity/PlayQueueActivity.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/activity/PlayQueueActivity.kt @@ -18,7 +18,8 @@ import io.casey.musikcube.remote.ui.shared.extension.setupDefaultRecyclerView import io.casey.musikcube.remote.ui.shared.mixin.DataProviderMixin import io.casey.musikcube.remote.ui.shared.mixin.ItemContextMenuMixin import io.casey.musikcube.remote.ui.shared.mixin.PlaybackMixin -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow +import io.casey.musikcube.remote.ui.shared.model.ITrackListSlidingWindow +import io.casey.musikcube.remote.ui.shared.model.DefaultSlidingWindow import io.casey.musikcube.remote.ui.shared.view.EmptyListView import io.reactivex.rxkotlin.subscribeBy @@ -26,7 +27,7 @@ class PlayQueueActivity : BaseActivity() { private var offlineQueue: Boolean = false private lateinit var data: DataProviderMixin private lateinit var playback: PlaybackMixin - private lateinit var tracks: TrackListSlidingWindow + private lateinit var tracks: DefaultSlidingWindow private lateinit var adapter: PlayQueueAdapter private lateinit var emptyView: EmptyListView @@ -45,7 +46,7 @@ class PlayQueueActivity : BaseActivity() { offlineQueue = playback.service.playlistQueryFactory.offline() val recyclerView = findViewById(R.id.recycler_view) - tracks = TrackListSlidingWindow(recyclerView, data.provider, queryFactory) + tracks = DefaultSlidingWindow(recyclerView, data.provider, queryFactory) tracks.setInitialPosition(intent.getIntExtra(EXTRA_PLAYING_INDEX, -1)) tracks.setOnMetadataLoadedListener(slidingWindowListener) adapter = PlayQueueAdapter(tracks, playback, adapterListener) @@ -120,7 +121,7 @@ class PlayQueueActivity : BaseActivity() { adapter.notifyDataSetChanged() } - private val slidingWindowListener = object : TrackListSlidingWindow.OnMetadataLoadedListener { + private val slidingWindowListener = object : ITrackListSlidingWindow.OnMetadataLoadedListener { override fun onReloaded(count: Int) = emptyView.update(data.provider.state, count) @@ -128,7 +129,7 @@ class PlayQueueActivity : BaseActivity() { } companion object { - private val EXTRA_PLAYING_INDEX = "extra_playing_index" + private const val EXTRA_PLAYING_INDEX = "extra_playing_index" fun getStartIntent(context: Context, playingIndex: Int): Intent { return Intent(context, PlayQueueActivity::class.java) diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/adapter/PlayQueueAdapter.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/adapter/PlayQueueAdapter.kt index 46aea10dd..702603e80 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/adapter/PlayQueueAdapter.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/playqueue/adapter/PlayQueueAdapter.kt @@ -10,9 +10,9 @@ import io.casey.musikcube.remote.service.websocket.model.ITrack import io.casey.musikcube.remote.ui.shared.extension.fallback import io.casey.musikcube.remote.ui.shared.extension.getColorCompat import io.casey.musikcube.remote.ui.shared.mixin.PlaybackMixin -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow +import io.casey.musikcube.remote.ui.shared.model.DefaultSlidingWindow -class PlayQueueAdapter(val tracks: TrackListSlidingWindow, +class PlayQueueAdapter(val tracks: DefaultSlidingWindow, val playback: PlaybackMixin, val listener: EventListener): RecyclerView.Adapter() { diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/BaseSlidingWindow.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/BaseSlidingWindow.kt new file mode 100644 index 000000000..1f54ed0a0 --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/BaseSlidingWindow.kt @@ -0,0 +1,86 @@ +package io.casey.musikcube.remote.ui.shared.model + +import android.support.v7.widget.RecyclerView +import com.simplecityapps.recyclerview_fastscroll.interfaces.OnFastScrollStateChangeListener +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import io.casey.musikcube.remote.service.websocket.model.IDataProvider +import io.casey.musikcube.remote.service.websocket.model.ITrack +import io.reactivex.disposables.CompositeDisposable + +abstract class BaseSlidingWindow( + private val recyclerView: FastScrollRecyclerView, + private val dataProvider: IDataProvider) : ITrackListSlidingWindow +{ + private var scrollState = RecyclerView.SCROLL_STATE_IDLE + private var fastScrollerActive = false + + protected var disposables = CompositeDisposable() + protected var loadedListener: ITrackListSlidingWindow.OnMetadataLoadedListener? = null + protected var connected = false + + protected class CacheEntry { + internal var value: ITrack? = null + internal var dirty: Boolean = false + } + + final override var count = 0 + protected set(count) { + field = count + invalidate() + notifyAdapterChanged() + notifyMetadataLoaded(0, 0) + } + + override fun pause() { + connected = false + recyclerView.removeOnScrollListener(recyclerViewScrollListener) + disposables.dispose() + disposables = CompositeDisposable() + } + + override fun resume() { + disposables.add(dataProvider.observePlayQueue() + .subscribe({ requery() }, { /* error */ })) + + recyclerView.setStateChangeListener(fastScrollStateChangeListener) + recyclerView.addOnScrollListener(recyclerViewScrollListener) + connected = true + fastScrollerActive = false + } + + override fun setOnMetadataLoadedListener(loadedListener: ITrackListSlidingWindow.OnMetadataLoadedListener) { + this.loadedListener = loadedListener + } + + protected abstract fun invalidate() + protected abstract fun getPageAround(index: Int) + + protected fun notifyAdapterChanged() = + recyclerView.adapter.notifyDataSetChanged() + + protected fun notifyMetadataLoaded(offset: Int, count: Int) = + loadedListener?.onMetadataLoaded(offset, count) + + protected fun scrolling(): Boolean = + scrollState != RecyclerView.SCROLL_STATE_IDLE || fastScrollerActive + + private val recyclerViewScrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) { + scrollState = newState + if (!scrolling()) { + notifyAdapterChanged() + } + } + } + + private val fastScrollStateChangeListener = object: OnFastScrollStateChangeListener { + override fun onFastScrollStop() { + fastScrollerActive = false + requery() + } + + override fun onFastScrollStart() { + fastScrollerActive = true + } + } +} diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/DefaultSlidingWindow.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/DefaultSlidingWindow.kt new file mode 100644 index 000000000..ef1b8ca3c --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/DefaultSlidingWindow.kt @@ -0,0 +1,132 @@ +package io.casey.musikcube.remote.ui.shared.model + +import android.util.Log +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import io.casey.musikcube.remote.service.websocket.model.IDataProvider +import io.casey.musikcube.remote.service.websocket.model.ITrack +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory +import io.reactivex.rxkotlin.subscribeBy + +class DefaultSlidingWindow( + private val recyclerView: FastScrollRecyclerView, + dataProvider: IDataProvider, + private val queryFactory: ITrackListQueryFactory) + : BaseSlidingWindow(recyclerView, dataProvider) +{ + private var queryOffset = -1 + private var queryLimit = -1 + private var initialPosition = -1 + private var windowSize = DEFAULT_WINDOW_SIZE + + private val cache = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size >= MAX_SIZE + } + + override fun requery() { + if (queryFactory.offline() || connected) { + cancelMessages() + + var queried = false + val countObservable = queryFactory.count() + + if (countObservable != null) { + countObservable.subscribeBy( + onNext = { newCount -> + count = newCount + + if (initialPosition != -1) { + recyclerView.scrollToPosition(initialPosition) + initialPosition = -1 + } + + loadedListener?.onReloaded(count) + }, + onError = { _ -> + Log.d("DefaultSlidingWindow", "message send failed, likely canceled") + }) + + queried = true + } + + if (!queried) { + count = 0 + loadedListener?.onReloaded(0) + } + } + } + + override fun getTrack(index: Int): ITrack? { + val track = cache[index] + + if (track == null || track.dirty) { + if (!scrolling()) { + getPageAround(index) + } + } + + return track?.value + } + + fun setInitialPosition(initialIndex: Int) { + initialPosition = initialIndex + } + + override fun invalidate() { + cancelMessages() + for (entry in cache.values) { + entry.dirty = true + } + } + + private fun cancelMessages() { + queryLimit = -1 + queryOffset = queryLimit + } + + override fun getPageAround(index: Int) { + if (!connected || scrolling()) { + return + } + + if (index >= queryOffset && index <= queryOffset + queryLimit) { + return /* already in flight */ + } + + val offset = Math.max(0, index - 10) /* snag a couple before */ + val limit = windowSize + + val pageRequest = queryFactory.page(offset, limit) + + if (pageRequest != null) { + cancelMessages() + + queryOffset = offset + queryLimit = limit + + pageRequest.subscribeBy( + onNext = { response -> + queryLimit = -1 + queryOffset = queryLimit + + var i = 0 + response.forEach { track -> + val entry = CacheEntry() + entry.dirty = false + entry.value = track + cache[offset + i++] = entry + } + + notifyAdapterChanged() + notifyMetadataLoaded(offset, i) + }, + onError = { _ -> + Log.d("DefaultSlidingWindow", "message send failed, likely canceled") + }) + } + } + + companion object { + private const val MAX_SIZE = 150 + private const val DEFAULT_WINDOW_SIZE = 75 + } +} diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/ITrackListSlidingWindow.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/ITrackListSlidingWindow.kt new file mode 100644 index 000000000..62c736dc6 --- /dev/null +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/ITrackListSlidingWindow.kt @@ -0,0 +1,17 @@ +package io.casey.musikcube.remote.ui.shared.model + +import io.casey.musikcube.remote.service.websocket.model.ITrack + +interface ITrackListSlidingWindow { + interface OnMetadataLoadedListener { + fun onMetadataLoaded(offset: Int, count: Int) + fun onReloaded(count: Int) + } + + val count: Int + fun requery() + fun pause() + fun resume() + fun getTrack(index: Int): ITrack? + fun setOnMetadataLoadedListener(loadedListener: OnMetadataLoadedListener) +} \ No newline at end of file diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/TrackListSlidingWindow.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/TrackListSlidingWindow.kt deleted file mode 100644 index 3619c9fbb..000000000 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/shared/model/TrackListSlidingWindow.kt +++ /dev/null @@ -1,212 +0,0 @@ -package io.casey.musikcube.remote.ui.shared.model - -import android.support.v7.widget.RecyclerView -import android.util.Log -import com.simplecityapps.recyclerview_fastscroll.interfaces.OnFastScrollStateChangeListener -import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView -import io.casey.musikcube.remote.service.websocket.model.IDataProvider -import io.casey.musikcube.remote.service.websocket.model.ITrack -import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.subscribeBy - -class TrackListSlidingWindow(private val recyclerView: FastScrollRecyclerView, - private val dataProvider: IDataProvider, - private val queryFactory: TrackListSlidingWindow.QueryFactory) -{ - private var scrollState = RecyclerView.SCROLL_STATE_IDLE - private var fastScrollerActive = false - private var disposables = CompositeDisposable() - private var queryOffset = -1 - private var queryLimit = -1 - private var initialPosition = -1 - private var windowSize = DEFAULT_WINDOW_SIZE - private var loadedListener: OnMetadataLoadedListener? = null - internal var connected = false - - private class CacheEntry { - internal var value: ITrack? = null - internal var dirty: Boolean = false - } - - private val cache = object : LinkedHashMap() { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size >= MAX_SIZE - } - - interface OnMetadataLoadedListener { - fun onMetadataLoaded(offset: Int, count: Int) - fun onReloaded(count: Int) - } - - abstract class QueryFactory { - abstract fun count(): Observable? - abstract fun page(offset: Int, limit: Int): Observable>? - abstract fun offline(): Boolean - } - - var count = 0 - set(count) { - field = count - invalidateCache() - cancelMessages() - notifyAdapterChanged() - notifyMetadataLoaded(0, 0) - } - - private val fastScrollStateChangeListener = object: OnFastScrollStateChangeListener { - override fun onFastScrollStop() { - fastScrollerActive = false - requery() - } - - override fun onFastScrollStart() { - fastScrollerActive = true - } - } - - fun requery() { - if (queryFactory.offline() || connected) { - cancelMessages() - - var queried = false - val countObservable = queryFactory.count() - - if (countObservable != null) { - countObservable.subscribeBy( - onNext = { newCount -> - count = newCount - - if (initialPosition != -1) { - recyclerView.scrollToPosition(initialPosition) - initialPosition = -1 - } - - loadedListener?.onReloaded(count) - }, - onError = { _ -> - Log.d("TrackListSlidingWindow", "message send failed, likely canceled") - }) - - queried = true - } - - if (!queried) { - count = 0 - loadedListener?.onReloaded(0) - } - } - } - - fun pause() { - connected = false - recyclerView.removeOnScrollListener(recyclerViewScrollListener) - disposables.dispose() - disposables = CompositeDisposable() - } - - fun resume() { - disposables.add(dataProvider.observePlayQueue() - .subscribe({ requery() }, { /* error */ })) - - recyclerView.setStateChangeListener(fastScrollStateChangeListener) - recyclerView.addOnScrollListener(recyclerViewScrollListener) - connected = true - fastScrollerActive = false - } - - fun setInitialPosition(initialIndex: Int) { - initialPosition = initialIndex - } - - fun setOnMetadataLoadedListener(loadedListener: OnMetadataLoadedListener) { - this.loadedListener = loadedListener - } - - fun getTrack(index: Int): ITrack? { - val track = cache[index] - - if (track == null || track.dirty) { - if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { - getPageAround(index) - } - } - - return track?.value - } - - private fun invalidateCache() { - for (entry in cache.values) { - entry.dirty = true - } - } - - private fun cancelMessages() { - queryLimit = -1 - queryOffset = queryLimit - } - - private fun getPageAround(index: Int) { - if (!connected || scrolling()) { - return - } - - if (index >= queryOffset && index <= queryOffset + queryLimit) { - return /* already in flight */ - } - - val offset = Math.max(0, index - 10) /* snag a couple before */ - val limit = windowSize - - val pageRequest = queryFactory.page(offset, limit) - - if (pageRequest != null) { - cancelMessages() - - queryOffset = offset - queryLimit = limit - - pageRequest.subscribeBy( - onNext = { response -> - queryLimit = -1 - queryOffset = queryLimit - - var i = 0 - response.forEach { track -> - val entry = CacheEntry() - entry.dirty = false - entry.value = track - cache.put(offset + i++, entry) - } - - notifyAdapterChanged() - notifyMetadataLoaded(offset, i) - }, - onError = { _ -> - Log.d("TrackListSlidingWindow", "message send failed, likely canceled") - }) - } - } - - private fun notifyAdapterChanged() = - recyclerView.adapter.notifyDataSetChanged() - - private fun notifyMetadataLoaded(offset: Int, count: Int) = - loadedListener?.onMetadataLoaded(offset, count) - - private fun scrolling(): Boolean = - scrollState != RecyclerView.SCROLL_STATE_IDLE || fastScrollerActive - - private val recyclerViewScrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) { - scrollState = newState - if (!scrolling()) { - notifyAdapterChanged() - } - } - } - - companion object { - private val MAX_SIZE = 150 - val DEFAULT_WINDOW_SIZE = 75 - } -} diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/activity/TrackListActivity.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/activity/TrackListActivity.kt index 178f92463..6844b28b6 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/activity/TrackListActivity.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/activity/TrackListActivity.kt @@ -19,8 +19,9 @@ import io.casey.musikcube.remote.ui.shared.fragment.TransportFragment import io.casey.musikcube.remote.ui.shared.mixin.DataProviderMixin import io.casey.musikcube.remote.ui.shared.mixin.ItemContextMenuMixin import io.casey.musikcube.remote.ui.shared.mixin.PlaybackMixin -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow.QueryFactory +import io.casey.musikcube.remote.ui.shared.model.ITrackListSlidingWindow +import io.casey.musikcube.remote.ui.shared.model.DefaultSlidingWindow +import io.casey.musikcube.remote.service.websocket.model.ITrackListQueryFactory import io.casey.musikcube.remote.ui.shared.view.EmptyListView import io.casey.musikcube.remote.ui.shared.view.EmptyListView.Capability import io.casey.musikcube.remote.ui.tracks.adapter.TrackListAdapter @@ -30,7 +31,7 @@ import io.reactivex.Observable import io.reactivex.rxkotlin.subscribeBy class TrackListActivity : BaseActivity(), Filterable { - private lateinit var tracks: TrackListSlidingWindow + private lateinit var tracks: DefaultSlidingWindow private lateinit var emptyView: EmptyListView private lateinit var transport: TransportFragment private lateinit var adapter: TrackListAdapter @@ -66,7 +67,7 @@ class TrackListActivity : BaseActivity(), Filterable { val queryFactory = createCategoryQueryFactory(categoryType, categoryId) val recyclerView = findViewById(R.id.recycler_view) - tracks = TrackListSlidingWindow(recyclerView, data.provider, queryFactory) + tracks = DefaultSlidingWindow(recyclerView, data.provider, queryFactory) adapter = TrackListAdapter(tracks, eventListener, playback) setupDefaultRecyclerView(recyclerView, adapter) @@ -209,10 +210,10 @@ class TrackListActivity : BaseActivity(), Filterable { } } - private fun createCategoryQueryFactory(categoryType: String?, categoryId: Long): QueryFactory { + private fun createCategoryQueryFactory(categoryType: String?, categoryId: Long): ITrackListQueryFactory { if (isValidCategory(categoryType, categoryId)) { /* tracks for a specified category (album, artists, genres, etc */ - return object : QueryFactory() { + return object : ITrackListQueryFactory { override fun count(): Observable = data.provider.getTrackCountByCategory(categoryType ?: "", categoryId, lastFilter) @@ -225,7 +226,7 @@ class TrackListActivity : BaseActivity(), Filterable { } else { /* all tracks */ - return object : QueryFactory() { + return object : ITrackListQueryFactory { override fun count(): Observable = data.provider.getTrackCount(lastFilter) @@ -238,7 +239,7 @@ class TrackListActivity : BaseActivity(), Filterable { } } - private val slidingWindowListener = object : TrackListSlidingWindow.OnMetadataLoadedListener { + private val slidingWindowListener = object : ITrackListSlidingWindow.OnMetadataLoadedListener { override fun onReloaded(count: Int) { emptyView.update(data.provider.state, count) } diff --git a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/adapter/TrackListAdapter.kt b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/adapter/TrackListAdapter.kt index 4adfcb446..4a7e0e022 100644 --- a/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/adapter/TrackListAdapter.kt +++ b/src/musikdroid/app/src/main/java/io/casey/musikcube/remote/ui/tracks/adapter/TrackListAdapter.kt @@ -10,9 +10,9 @@ import io.casey.musikcube.remote.service.websocket.model.ITrack import io.casey.musikcube.remote.ui.shared.extension.fallback import io.casey.musikcube.remote.ui.shared.extension.getColorCompat import io.casey.musikcube.remote.ui.shared.mixin.PlaybackMixin -import io.casey.musikcube.remote.ui.shared.model.TrackListSlidingWindow +import io.casey.musikcube.remote.ui.shared.model.DefaultSlidingWindow -class TrackListAdapter(private val tracks: TrackListSlidingWindow, +class TrackListAdapter(private val tracks: DefaultSlidingWindow, private val listener: EventListener?, private var playback: PlaybackMixin) : RecyclerView.Adapter() {