mirror of
https://github.com/clangen/musikcube.git
synced 2025-02-23 18:40:02 +00:00
Refactor and additions to support play queue snapshotting, and the
ability to transfer playback context seamlessly between server and device.
This commit is contained in:
parent
0b3e1b2f19
commit
5b356c4991
@ -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 */
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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<Int> = dataProvider.getPlayQueueTracksCount()
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>> = dataProvider.getPlayQueueTracks(limit, offset)
|
||||
override fun offline(): Boolean = false
|
||||
|
@ -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<Int>? =
|
||||
dataProvider.getPlayQueueTracksCount(type)
|
||||
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>>? =
|
||||
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<Int>? {
|
||||
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<List<ITrack>>? {
|
||||
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<Int>? =
|
||||
snapshotQueryFactory?.count() ?: defaultQueryFactory.count()
|
||||
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>>? =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<List<ITrack>>
|
||||
fun getTracksByCategory(category: String, id: Long, limit: Int, offset: Int, filter: String = ""): Observable<List<ITrack>>
|
||||
|
||||
fun getPlayQueueTracksCount(filter: String = ""): Observable<Int>
|
||||
fun getPlayQueueTracks(filter: String = ""): Observable<List<ITrack>>
|
||||
fun getPlayQueueTracks(limit: Int, offset: Int, filter: String = ""): Observable<List<ITrack>>
|
||||
fun getPlayQueueTracksCount(type: PlayQueueType = PlayQueueType.Live): Observable<Int>
|
||||
fun getPlayQueueTracks(type: PlayQueueType = PlayQueueType.Live): Observable<List<ITrack>>
|
||||
fun getPlayQueueTracks(limit: Int, offset: Int, type: PlayQueueType = PlayQueueType.Live): Observable<List<ITrack>>
|
||||
fun getPlayQueueTrackIds(limit: Int, offset: Int, type: PlayQueueType = PlayQueueType.Live): Observable<List<String>>
|
||||
fun getPlayQueueTrackIds(type: PlayQueueType = PlayQueueType.Live): Observable<List<String>>
|
||||
fun snapshotPlayQueue(): Observable<Boolean>
|
||||
|
||||
fun getPlaylists(): Observable<List<IPlaylist>>
|
||||
|
||||
@ -49,7 +53,7 @@ interface IDataProvider {
|
||||
fun removeTracksFromPlaylist(playlistId: Long, externalIds: List<String>, sortOrders: List<Int>): Observable<Int>
|
||||
|
||||
fun listOutputDrivers(): Observable<IOutputs>
|
||||
fun setDefaultOutputDriver(driverName: String, deviceId: String = "default"): Observable<Boolean>
|
||||
fun setDefaultOutputDriver(driverName: String, deviceId: String = ""): Observable<Boolean>
|
||||
|
||||
fun getGainSettings(): Observable<IGainSettings>
|
||||
fun updateGainSettings(replayGainMode: ReplayGainMode, preampGain: Float): Observable<Boolean>
|
||||
|
@ -0,0 +1,9 @@
|
||||
package io.casey.musikcube.remote.service.websocket.model
|
||||
|
||||
import io.reactivex.Observable
|
||||
|
||||
interface ITrackListQueryFactory {
|
||||
fun count(): Observable<Int>?
|
||||
fun page(offset: Int, limit: Int): Observable<List<ITrack>>?
|
||||
fun offline(): Boolean
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package io.casey.musikcube.remote.service.websocket.model
|
||||
|
||||
enum class PlayQueueType(val rawValue: String) {
|
||||
Live("live"),
|
||||
Snapshot("snapshot");
|
||||
}
|
@ -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<String>): 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<List<ITrack>>? {
|
||||
val window = mutableSetOf<String>()
|
||||
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<ITrack>()
|
||||
for (i in 0 until max) {
|
||||
result.add(it[idList[offset + i]] ?: missing)
|
||||
}
|
||||
Observable.just(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun count(): Observable<Int> = Observable.just(idList.size)
|
||||
override fun offline(): Boolean = false
|
||||
|
||||
fun destroy() {
|
||||
dataProvider.destroy()
|
||||
}
|
||||
}
|
@ -163,11 +163,11 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun getPlayQueueTracksCount(filter: String): Observable<Int> {
|
||||
override fun getPlayQueueTracksCount(type: PlayQueueType): Observable<Int> {
|
||||
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<List<ITrack>> =
|
||||
getPlayQueueTracks(-1, -1, filter)
|
||||
override fun getPlayQueueTracks(type: PlayQueueType): Observable<List<ITrack>> =
|
||||
getPlayQueueTracks(-1, -1, type)
|
||||
|
||||
override fun getPlayQueueTracks(limit: Int, offset: Int, filter: String): Observable<List<ITrack>> {
|
||||
override fun getPlayQueueTracks(limit: Int, offset: Int, type: PlayQueueType): Observable<List<ITrack>> {
|
||||
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<List<String>> =
|
||||
getPlayQueueTrackIds(-1, -1, type)
|
||||
|
||||
override fun snapshotPlayQueue(): Observable<Boolean> {
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.SnapshotPlayQueue)
|
||||
.build()
|
||||
|
||||
return service.observe(message, client)
|
||||
.flatMap<Boolean> { socketMessage -> isSuccessful(socketMessage) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun getPlayQueueTrackIds(limit: Int, offset: Int, type: PlayQueueType): Observable<List<String>> {
|
||||
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<List<String>> { socketMessage -> toStringList(socketMessage) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun getPlaylists(): Observable<List<IPlaylist>> {
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryCategory)
|
||||
|
@ -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()
|
||||
|
@ -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) ?: ""
|
||||
|
@ -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<FastScrollRecyclerView>(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)
|
||||
|
@ -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<PlayQueueAdapter.ViewHolder>()
|
||||
{
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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<Int, CacheEntry>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, CacheEntry>): 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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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<Int, CacheEntry>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, CacheEntry>): Boolean = size >= MAX_SIZE
|
||||
}
|
||||
|
||||
interface OnMetadataLoadedListener {
|
||||
fun onMetadataLoaded(offset: Int, count: Int)
|
||||
fun onReloaded(count: Int)
|
||||
}
|
||||
|
||||
abstract class QueryFactory {
|
||||
abstract fun count(): Observable<Int>?
|
||||
abstract fun page(offset: Int, limit: Int): Observable<List<ITrack>>?
|
||||
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
|
||||
}
|
||||
}
|
@ -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<FastScrollRecyclerView>(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<Int> =
|
||||
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<Int> =
|
||||
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)
|
||||
}
|
||||
|
@ -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<TrackListAdapter.ViewHolder>()
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user