mirror of
https://github.com/clangen/musikcube.git
synced 2025-02-11 09:40:26 +00:00
Incremental work to support playlist editing.
This commit is contained in:
parent
fc99911ad1
commit
78c57b8f10
@ -7,7 +7,8 @@ musikcube:
|
||||
|
||||
musikdroid:
|
||||
|
||||
* gapless playback!
|
||||
* gapless playback (for supported media)! enable in settings > playback
|
||||
engine > "ExoPlayer Gapless (experimental)"
|
||||
* album art is now displayed in album rows when browsing
|
||||
* context menus on most screens with the ability to switch between related
|
||||
content (e.g. albums by this artist, artists in this genre, etc)
|
||||
@ -22,7 +23,6 @@ musikdroid:
|
||||
* updated Glide from v3 -> v4
|
||||
* updated to Android Studio 3.0.1 and related tooling
|
||||
|
||||
|
||||
sdk:
|
||||
|
||||
* removed all Destroy() methods, standardized on Release() across the board
|
||||
|
@ -11,7 +11,7 @@ because `musikdroid` is not available in the Google Play store, it uses [fabric.
|
||||
|
||||
this should allow you to build and test locally without special keys. TODO: simplify
|
||||
|
||||
the project is currently built using `Android Studio 3.0 Canary 6`
|
||||
the project is currently built using `Android Studio 3.0.1`
|
||||
|
||||
# attribution
|
||||
|
||||
|
@ -51,6 +51,11 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ui.tracks.activity.EditPlaylistActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="io.casey.musikcube.remote.ui.category.activity.CategoryBrowseActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
@ -6,40 +6,58 @@ import android.os.Looper
|
||||
import com.uacf.taskrunner.Runner
|
||||
import com.uacf.taskrunner.Task
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
import io.reactivex.subjects.Subject
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
abstract class ViewModel<ListenerT>(protected val runner: Runner? = null): Runner.TaskCallbacks {
|
||||
abstract class ViewModel<T>(protected val runner: Runner? = null): Runner.TaskCallbacks {
|
||||
val id: Long = nextId.incrementAndGet()
|
||||
private val publisher by lazy { createSubject() }
|
||||
|
||||
interface Provider {
|
||||
fun <T: ViewModel<*>> createViewModel(): T?
|
||||
}
|
||||
|
||||
protected var listener: ListenerT? = null
|
||||
protected var listener: T? = null
|
||||
private set
|
||||
|
||||
fun onPause() {
|
||||
open fun onPause() {
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
open fun onResume() {
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
open fun onDestroy() {
|
||||
listener = null
|
||||
handler.postDelayed(cleanup, cleanupDelayMs)
|
||||
}
|
||||
|
||||
fun observe(listener: ListenerT) {
|
||||
this.listener = listener
|
||||
open fun onCleanup() {
|
||||
|
||||
}
|
||||
|
||||
fun observe(): Observable<T> {
|
||||
return publisher
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
val context: Context = Application.instance!!
|
||||
|
||||
internal val cleanup = object: Runnable {
|
||||
override fun run() {
|
||||
listener = null
|
||||
idToInstance.remove(id)
|
||||
}
|
||||
internal val cleanup = Runnable {
|
||||
listener = null
|
||||
idToInstance.remove(id)
|
||||
onCleanup()
|
||||
}
|
||||
|
||||
protected fun publish(value: T) {
|
||||
publisher.onNext(value)
|
||||
}
|
||||
|
||||
open fun createSubject(): Subject<T> {
|
||||
return PublishSubject.create<T>()
|
||||
}
|
||||
|
||||
override fun onTaskError(name: String?, id: Long, task: Task<*, *>?, error: Throwable?) {
|
||||
|
@ -373,7 +373,6 @@ class RemotePlaybackService : IPlaybackService {
|
||||
|
||||
override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() {
|
||||
override fun count(): Observable<Int> = dataProvider.getPlayQueueTracksCount()
|
||||
override fun all(): Observable<List<ITrack>>? = dataProvider.getPlayQueueTracks()
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>> = dataProvider.getPlayQueueTracks(limit, offset)
|
||||
override fun offline(): Boolean = false
|
||||
}
|
||||
|
@ -774,20 +774,6 @@ class StreamingPlaybackService(context: Context) : IPlaybackService {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun all(): Observable<List<ITrack>>? {
|
||||
val params = params
|
||||
if (params != null) {
|
||||
if (Strings.notEmpty(params.category) && (params.categoryId >= 0)) {
|
||||
return dataProvider.getTracksByCategory(
|
||||
params.category ?: "", params.categoryId, params.filter)
|
||||
}
|
||||
else {
|
||||
return dataProvider.getTracks(params.filter)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>>? {
|
||||
val params = params
|
||||
if (params != null) {
|
||||
|
@ -77,6 +77,7 @@ class Messages {
|
||||
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"
|
||||
|
@ -330,6 +330,8 @@ class WebSocketService constructor(private val context: Context) {
|
||||
subject.onError(ex)
|
||||
}
|
||||
|
||||
subject.doOnDispose { cancelMessage(mrd.id) }
|
||||
|
||||
if (!intercepted) {
|
||||
socket?.sendText(message.toString())
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ interface IDataProvider {
|
||||
fun getTracks(externalIds: Set<String>): Observable<Map<String, ITrack>>
|
||||
|
||||
fun getTrackCountByCategory(category: String, id: Long, filter: String = ""): Observable<Int>
|
||||
fun getTrackIdsByCategory(category: String, id: Long, filter: String = ""): Observable<List<String>>
|
||||
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>>
|
||||
|
||||
@ -36,6 +37,7 @@ interface IDataProvider {
|
||||
fun createPlaylist(playlistName: String, categoryType: String = "", categoryId: Long = -1, filter: String = ""): Observable<Long>
|
||||
fun createPlaylist(playlistName: String, tracks: List<ITrack> = ArrayList()): Observable<Long>
|
||||
fun createPlaylistWithExternalIds(playlistName: String, externalIds: List<String> = ArrayList()): Observable<Long>
|
||||
fun overwritePlaylistWithExternalIds(playlistId: Long, externalIds: List<String> = ArrayList()): Observable<Long>
|
||||
fun appendToPlaylist(playlistId: Long, categoryType: String = "", categoryId: Long = -1, filter: String = "", offset: Long = -1): Observable<Boolean>
|
||||
fun appendToPlaylist(playlistId: Long, tracks: List<ITrack> = ArrayList(), offset: Long = -1): Observable<Boolean>
|
||||
fun appendToPlaylist(playlistId: Long, categoryValue: ICategoryValue): Observable<Boolean>
|
||||
|
@ -116,6 +116,21 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun getTrackIdsByCategory(category: String, id: Long, filter: String): Observable<List<String>> {
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.QueryTracksByCategory)
|
||||
.addOption(Messages.Key.FILTER, filter)
|
||||
.addOption(Messages.Key.CATEGORY, category)
|
||||
.addOption(Messages.Key.ID, id)
|
||||
.addOption(Messages.Key.COUNT_ONLY, false)
|
||||
.addOption(Messages.Key.IDS_ONLY, true)
|
||||
.build()
|
||||
|
||||
return service.observe(message, client)
|
||||
.flatMap<List<String>> { socketMessage -> toStringList(socketMessage) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun getTracksByCategory(category: String, id: Long, filter: String): Observable<List<ITrack>> =
|
||||
getTracksByCategory(category, id, -1, -1, filter)
|
||||
|
||||
@ -241,7 +256,7 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
|
||||
|
||||
override fun createPlaylistWithExternalIds(playlistName: String, externalIds: List<String>): Observable<Long> {
|
||||
if (playlistName.isBlank()) {
|
||||
return Observable.just(0)
|
||||
return Observable.just(-1L)
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray()
|
||||
@ -258,6 +273,25 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun overwritePlaylistWithExternalIds(playlistId: Long, externalIds: List<String>): Observable<Long> {
|
||||
if (playlistId < 0L) {
|
||||
return Observable.just(-1L)
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray()
|
||||
externalIds.forEach { jsonArray.put(it) }
|
||||
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.SavePlaylist)
|
||||
.addOption(Messages.Key.PLAYLIST_ID, playlistId)
|
||||
.addOption(Messages.Key.EXTERNAL_IDS, jsonArray)
|
||||
.build()
|
||||
|
||||
return service.observe(message, client)
|
||||
.flatMap<Long> { socketMessage -> extractId(socketMessage, Messages.Key.PLAYLIST_ID) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun appendToPlaylist(playlistId: Long, categoryType: String, categoryId: Long, filter: String, offset: Long): Observable<Boolean> {
|
||||
val message = SocketMessage.Builder
|
||||
.request(Messages.Request.AppendToPlaylist)
|
||||
@ -461,6 +495,15 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
|
||||
return Observable.just(albums)
|
||||
}
|
||||
|
||||
private fun toStringList(socketMessage: SocketMessage): Observable<List<String>> {
|
||||
val strings = ArrayList<String>()
|
||||
val json = socketMessage.getJsonArrayOption(Messages.Key.DATA, JSONArray())!!
|
||||
for (i in 0 until json.length()) {
|
||||
strings.add(json.getString(i))
|
||||
}
|
||||
return Observable.just(strings)
|
||||
}
|
||||
|
||||
private fun toCount(message: SocketMessage): Observable<Int> {
|
||||
return Observable.just(message.getIntOption(Messages.Key.COUNT, 0))
|
||||
}
|
||||
|
@ -410,30 +410,29 @@ class MainActivity : BaseActivity() {
|
||||
private fun scheduleUpdateTime(immediate: Boolean) {
|
||||
handler.removeCallbacks(updateTimeRunnable)
|
||||
handler.postDelayed(updateTimeRunnable, (if (immediate) 0 else 1000).toLong())
|
||||
handler.removeCallbacks(updateTimeRunnable)
|
||||
}
|
||||
|
||||
private val updateTimeRunnable = object: Runnable {
|
||||
override fun run() {
|
||||
val duration = playback.service.duration
|
||||
val current: Double = if (seekbarValue == -1) playback.service.currentTime else seekbarValue.toDouble()
|
||||
private val updateTimeRunnable = Runnable {
|
||||
val duration = playback.service.duration
|
||||
val current: Double = if (seekbarValue == -1) playback.service.currentTime else seekbarValue.toDouble()
|
||||
|
||||
currentTime.text = Duration.format(current)
|
||||
totalTime.text = Duration.format(duration)
|
||||
seekbar.max = duration.toInt()
|
||||
seekbar.progress = current.toInt()
|
||||
seekbar.secondaryProgress = playback.service.bufferedTime.toInt()
|
||||
currentTime.text = Duration.format(current)
|
||||
totalTime.text = Duration.format(duration)
|
||||
seekbar.max = duration.toInt()
|
||||
seekbar.progress = current.toInt()
|
||||
seekbar.secondaryProgress = playback.service.bufferedTime.toInt()
|
||||
|
||||
var currentTimeColor = R.color.theme_foreground
|
||||
if (playback.service.state === PlaybackState.Paused) {
|
||||
currentTimeColor =
|
||||
if (++blink % 2 == 0) R.color.theme_foreground
|
||||
else R.color.theme_blink_foreground
|
||||
}
|
||||
|
||||
currentTime.setTextColor(getColorCompat(currentTimeColor))
|
||||
|
||||
scheduleUpdateTime(false)
|
||||
var currentTimeColor = R.color.theme_foreground
|
||||
if (playback.service.state === PlaybackState.Paused) {
|
||||
currentTimeColor =
|
||||
if (++blink % 2 == 0) R.color.theme_foreground
|
||||
else R.color.theme_blink_foreground
|
||||
}
|
||||
|
||||
currentTime.setTextColor(getColorCompat(currentTimeColor))
|
||||
|
||||
scheduleUpdateTime(false)
|
||||
}
|
||||
|
||||
private val muteListener = { _: CompoundButton, b: Boolean ->
|
||||
|
@ -23,7 +23,7 @@ class PlayQueueAdapter(val tracks: TrackListSlidingWindow,
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.play_queue_row, parent, false)
|
||||
val view = inflater.inflate(R.layout.playlist_track_row, parent, false)
|
||||
val action = view.findViewById<View>(R.id.action)
|
||||
view.setOnClickListener{ v -> listener.onItemClicked(v.tag as Int) }
|
||||
action.setOnClickListener{ v -> listener.onActionClicked(v, v.tag as ITrack) }
|
||||
|
@ -28,15 +28,12 @@ import io.casey.musikcube.remote.util.Strings
|
||||
val EXTRA_ACTIVITY_TITLE = "extra_title"
|
||||
|
||||
fun AppCompatActivity.setupDefaultRecyclerView(
|
||||
recyclerView: RecyclerView,
|
||||
adapter: RecyclerView.Adapter<*>) {
|
||||
recyclerView: RecyclerView, adapter: RecyclerView.Adapter<*>)
|
||||
{
|
||||
val layoutManager = LinearLayoutManager(this)
|
||||
|
||||
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
|
||||
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ import io.casey.musikcube.remote.ui.shared.extension.showErrorSnackbar
|
||||
import io.casey.musikcube.remote.ui.shared.extension.showKeyboard
|
||||
import io.casey.musikcube.remote.ui.shared.extension.showSnackbar
|
||||
import io.casey.musikcube.remote.ui.shared.fragment.BaseDialogFragment
|
||||
import io.casey.musikcube.remote.ui.tracks.activity.EditPlaylistActivity
|
||||
import io.casey.musikcube.remote.ui.tracks.activity.TrackListActivity
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.rxkotlin.subscribeBy
|
||||
@ -233,6 +234,9 @@ class ItemContextMenuMixin(private val activity: AppCompatActivity,
|
||||
R.id.menu_playlist_delete -> {
|
||||
ConfirmDeletePlaylistDialog.show(activity, this, playlistName, playlistId)
|
||||
}
|
||||
R.id.menu_playlist_edit -> {
|
||||
activity.startActivity(EditPlaylistActivity.getStartIntent(activity, playlistId))
|
||||
}
|
||||
R.id.menu_playlist_rename -> {
|
||||
EnterPlaylistNameDialog.showForRename(activity, this, playlistName, playlistId)
|
||||
}
|
||||
|
@ -7,7 +7,12 @@ import io.casey.musikcube.remote.framework.ViewModel
|
||||
class ViewModelMixin(private val provider: ViewModel.Provider): MixinBase() {
|
||||
private var viewModel: ViewModel<*>? = null
|
||||
|
||||
fun <T: ViewModel<*>> get(): T? = this.viewModel as T?
|
||||
fun <T: ViewModel<*>> get(): T? {
|
||||
if (viewModel == null) {
|
||||
viewModel = provider.createViewModel()
|
||||
}
|
||||
return viewModel as T?
|
||||
}
|
||||
|
||||
override fun onCreate(bundle: Bundle) {
|
||||
super.onCreate(bundle)
|
||||
|
@ -40,7 +40,6 @@ class TrackListSlidingWindow(private val recyclerView: FastScrollRecyclerView,
|
||||
|
||||
abstract class QueryFactory {
|
||||
abstract fun count(): Observable<Int>?
|
||||
abstract fun all(): Observable<List<ITrack>>?
|
||||
abstract fun page(offset: Int, limit: Int): Observable<List<ITrack>>?
|
||||
abstract fun offline(): Boolean
|
||||
}
|
||||
|
@ -0,0 +1,106 @@
|
||||
package io.casey.musikcube.remote.ui.tracks.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.support.v7.widget.helper.ItemTouchHelper
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.framework.ViewModel
|
||||
import io.casey.musikcube.remote.ui.shared.activity.BaseActivity
|
||||
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.ViewModelMixin
|
||||
import io.casey.musikcube.remote.ui.tracks.adapter.EditPlaylistAdapter
|
||||
import io.casey.musikcube.remote.ui.tracks.model.EditPlaylistViewModel
|
||||
import io.casey.musikcube.remote.ui.tracks.model.EditPlaylistViewModel.Status
|
||||
import io.reactivex.rxkotlin.subscribeBy
|
||||
|
||||
class EditPlaylistActivity: BaseActivity() {
|
||||
private lateinit var viewModel: EditPlaylistViewModel
|
||||
private lateinit var data: DataProviderMixin
|
||||
private lateinit var adapter: EditPlaylistAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
mixin(ViewModelMixin(this))
|
||||
data = mixin(DataProviderMixin())
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.recycler_view_activity)
|
||||
viewModel = getViewModel()!!
|
||||
viewModel.attach(data.provider)
|
||||
val recycler = findViewById<RecyclerView>(R.id.recycler_view)
|
||||
val touchHelper = ItemTouchHelper(touchHelperCallback)
|
||||
touchHelper.attachToRecyclerView(recycler)
|
||||
adapter = EditPlaylistAdapter(viewModel, touchHelper)
|
||||
setupDefaultRecyclerView(recycler, adapter)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_playlist_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.action_save) {
|
||||
viewModel.save().subscribeBy(
|
||||
onNext = { playlistId ->
|
||||
if (playlistId != -1L) {
|
||||
finish()
|
||||
}
|
||||
else {
|
||||
/* TODO ERROR SNACKBAR */
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
/* TODO ERROR SNACKBAR */
|
||||
})
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
disposables.add(viewModel.observe().subscribeBy(
|
||||
onNext = { status ->
|
||||
if (status == Status.Updated) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
},
|
||||
onError = { }
|
||||
))
|
||||
}
|
||||
|
||||
override fun <T: ViewModel<*>> createViewModel(): T? {
|
||||
return EditPlaylistViewModel(intent.extras.getLong(EXTRA_PLAYLIST_ID, -1L)) as T
|
||||
}
|
||||
|
||||
private val touchHelperCallback = object:ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||
ItemTouchHelper.LEFT)
|
||||
{
|
||||
override fun onMove(recyclerView: RecyclerView?, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
|
||||
val from = viewHolder.adapterPosition
|
||||
val to = target.adapterPosition
|
||||
viewModel.move(from, to)
|
||||
adapter.notifyItemMoved(from, to)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
viewModel.remove(viewHolder.adapterPosition)
|
||||
adapter.notifyItemRemoved(viewHolder.adapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXTRA_PLAYLIST_ID = "extra_playlist_id"
|
||||
|
||||
fun getStartIntent(context: Context, playlistId: Long): Intent {
|
||||
return Intent(context, EditPlaylistActivity::class.java)
|
||||
.putExtra(EXTRA_PLAYLIST_ID, playlistId)
|
||||
}
|
||||
}
|
||||
}
|
@ -185,9 +185,6 @@ class TrackListActivity : BaseActivity(), Filterable {
|
||||
override fun count(): Observable<Int> =
|
||||
data.provider.getTrackCountByCategory(categoryType ?: "", categoryId, lastFilter)
|
||||
|
||||
override fun all(): Observable<List<ITrack>>? =
|
||||
data.provider.getTracksByCategory(categoryType ?: "", categoryId, lastFilter)
|
||||
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>> =
|
||||
data.provider.getTracksByCategory(categoryType ?: "", categoryId, limit, offset, lastFilter)
|
||||
|
||||
@ -201,9 +198,6 @@ class TrackListActivity : BaseActivity(), Filterable {
|
||||
override fun count(): Observable<Int> =
|
||||
data.provider.getTrackCount(lastFilter)
|
||||
|
||||
override fun all(): Observable<List<ITrack>>? =
|
||||
data.provider.getTracks(lastFilter)
|
||||
|
||||
override fun page(offset: Int, limit: Int): Observable<List<ITrack>> =
|
||||
data.provider.getTracks(limit, offset, lastFilter)
|
||||
|
||||
|
@ -0,0 +1,70 @@
|
||||
package io.casey.musikcube.remote.ui.tracks.adapter
|
||||
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.support.v7.widget.helper.ItemTouchHelper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import io.casey.musikcube.remote.R
|
||||
import io.casey.musikcube.remote.service.websocket.model.ITrack
|
||||
import io.casey.musikcube.remote.ui.shared.extension.fallback
|
||||
import io.casey.musikcube.remote.ui.tracks.model.EditPlaylistViewModel
|
||||
|
||||
class EditPlaylistAdapter(private val viewModel: EditPlaylistViewModel,
|
||||
private val touchHelper: ItemTouchHelper): RecyclerView.Adapter<EditPlaylistAdapter.ViewHolder>() {
|
||||
override fun getItemCount(): Int {
|
||||
return viewModel.count
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val view = inflater.inflate(R.layout.edit_playlist_track_row, parent, false)
|
||||
val holder = ViewHolder(view)
|
||||
val drag = view.findViewById<View>(R.id.dragHandle)
|
||||
val swipe = view.findViewById<View>(R.id.swipeHandle)
|
||||
view.setOnClickListener(emptyClickListener)
|
||||
drag.setOnTouchListener(dragTouchHandler)
|
||||
swipe.setOnTouchListener(dragTouchHandler)
|
||||
drag.tag = holder
|
||||
swipe.tag = holder
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(viewModel[position])
|
||||
}
|
||||
|
||||
private val emptyClickListener = object: View.OnClickListener {
|
||||
override fun onClick(view: View?) {
|
||||
/* we do this so we get a ripple effect when the user touches the view,
|
||||
so there's an indication something is happening before the drag starts */
|
||||
}
|
||||
}
|
||||
|
||||
private val dragTouchHandler = object: View.OnTouchListener {
|
||||
override fun onTouch(view: View, event: MotionEvent?): Boolean {
|
||||
if (event?.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
if (view.id == R.id.dragHandle) {
|
||||
touchHelper.startDrag(view.tag as RecyclerView.ViewHolder)
|
||||
}
|
||||
else if (view.id == R.id.swipeHandle) {
|
||||
touchHelper.startSwipe(view.tag as RecyclerView.ViewHolder)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder internal constructor(internal val view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val title = itemView.findViewById<TextView>(R.id.title)
|
||||
private val subtitle = itemView.findViewById<TextView>(R.id.subtitle)
|
||||
|
||||
fun bind(track: ITrack) {
|
||||
title.text = fallback(track.title, "-")
|
||||
subtitle.text = fallback(track.albumArtist, "-")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
package io.casey.musikcube.remote.ui.tracks.model
|
||||
|
||||
import io.casey.musikcube.remote.framework.ViewModel
|
||||
import io.casey.musikcube.remote.service.websocket.Messages.Category.Companion.PLAYLISTS
|
||||
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.reactivex.Observable
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.rxkotlin.subscribeBy
|
||||
import org.json.JSONObject
|
||||
|
||||
class EditPlaylistViewModel(private val playlistId: Long): ViewModel<EditPlaylistViewModel.Status>() {
|
||||
enum class Status { NotLoaded, Error, Loading, Saving, Updated }
|
||||
|
||||
private data class CacheEntry(var track: ITrack, var dirty: Boolean = false)
|
||||
|
||||
private var metadataDisposable: Disposable? = null
|
||||
private var requestOffset = -1
|
||||
private var dataProvider: IDataProvider? = null
|
||||
private var externalIds: MutableList<String> = mutableListOf()
|
||||
|
||||
private val cache = object : LinkedHashMap<String, CacheEntry>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CacheEntry>): Boolean = size >= MAX_SIZE
|
||||
}
|
||||
|
||||
var modified: Boolean = false
|
||||
private set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
fun attach(dataProvider: IDataProvider) {
|
||||
this.dataProvider = dataProvider
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
this.dataProvider = null
|
||||
}
|
||||
|
||||
var status: Status = Status.NotLoaded
|
||||
private set(value) {
|
||||
field = value
|
||||
publish(value)
|
||||
}
|
||||
|
||||
val count: Int
|
||||
get() {
|
||||
if (externalIds.isEmpty() && status != Status.Loading) {
|
||||
refreshTrackIds()
|
||||
}
|
||||
return externalIds.size
|
||||
}
|
||||
|
||||
operator fun get(index: Int): ITrack {
|
||||
val entry = cache[externalIds[index]]
|
||||
if (entry == null) {
|
||||
refreshPageAround(index)
|
||||
}
|
||||
return entry?.track ?: DEFAULT_TRACK
|
||||
}
|
||||
|
||||
fun save(): Observable<Long> {
|
||||
if (!modified) {
|
||||
return Observable.just(playlistId)
|
||||
}
|
||||
|
||||
return dataProvider?.overwritePlaylistWithExternalIds(playlistId, externalIds.toList()) ?: Observable.just(-1L)
|
||||
}
|
||||
|
||||
fun remove(index: Int) {
|
||||
externalIds.removeAt(index)
|
||||
}
|
||||
|
||||
fun move(from: Int, to: Int) {
|
||||
val id = externalIds.removeAt(from)
|
||||
externalIds.add(if (to > from) (to - 1) else to, id)
|
||||
}
|
||||
|
||||
private fun refreshTrackIds() {
|
||||
status = Status.Loading
|
||||
dataProvider?.let {
|
||||
status = Status.Loading
|
||||
|
||||
it.getTrackIdsByCategory(PLAYLISTS, playlistId).subscribeBy(
|
||||
onNext = { result ->
|
||||
externalIds = result.toMutableList()
|
||||
status = Status.Updated
|
||||
},
|
||||
onError = {
|
||||
status = Status.Error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshPageAround(offset: Int) {
|
||||
if (requestOffset != -1 && offset >= requestOffset && offset < requestOffset + PAGE_SIZE) {
|
||||
return /* in flight */
|
||||
}
|
||||
|
||||
dataProvider?.let {
|
||||
metadataDisposable?.dispose()
|
||||
metadataDisposable = null
|
||||
|
||||
requestOffset = Math.max(0, offset - PAGE_SIZE / 4)
|
||||
val end = Math.min(externalIds.size, requestOffset + PAGE_SIZE)
|
||||
val ids = mutableSetOf<String>()
|
||||
for (i in requestOffset until end) {
|
||||
val id = externalIds[i]
|
||||
val entry = cache[id]
|
||||
if (entry == null || entry.dirty) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.isNotEmpty()) {
|
||||
status = Status.Loading
|
||||
|
||||
metadataDisposable = it.getTracks(ids)
|
||||
.flatMapIterable { list: Map<String, ITrack> -> list.asIterable() }
|
||||
.subscribeBy(
|
||||
onNext = { entry: Map.Entry<String, ITrack> ->
|
||||
cache.put(entry.key, CacheEntry(entry.value))
|
||||
},
|
||||
onError = {
|
||||
status = Status.Error
|
||||
requestOffset = -1
|
||||
},
|
||||
onComplete = {
|
||||
status = Status.Updated
|
||||
requestOffset = -1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_TRACK = RemoteTrack(JSONObject())
|
||||
private val PAGE_SIZE = 40
|
||||
private val MAX_SIZE = 150
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<vector android:height="16dp" android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="?attr/colorControlNormal" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
</vector>
|
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M3,15h18v-2L3,13v2zM3,19h18v-2L3,17v2zM3,11h18L21,9L3,9v2zM3,5v2h18L21,5L3,5z"/>
|
||||
</vector>
|
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8.46,11.88l1.41,-1.41L12,12.59l2.12,-2.12 1.41,1.41L13.41,14l2.12,2.12 -1.41,1.41L12,15.41l-2.12,2.12 -1.41,-1.41L10.59,14l-2.13,-2.12zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z"/>
|
||||
</vector>
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorControlHighlight">
|
||||
<item android:drawable="@color/theme_background"/>
|
||||
</ripple>
|
@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/rowView"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/opaque_row_background"
|
||||
android:minHeight="52dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/dragHandle"
|
||||
android:src="@drawable/ic_reorder"
|
||||
android:scaleType="center"
|
||||
android:padding="4dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:gravity="center"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toEndOf="@+id/dragHandle"
|
||||
android:layout_toStartOf="@+id/swipeHandle"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_foreground"
|
||||
tools:text="title"/>
|
||||
|
||||
<TextView
|
||||
android:textSize="12dp"
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:textColor="@color/theme_disabled_foreground"
|
||||
tools:text="subtitle"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/swipeHandle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:paddingStart="4dp"
|
||||
android:minHeight="52dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_alignParentEnd="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginEnd="-4dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_leftarrow" />
|
||||
|
||||
<ImageView
|
||||
android:background="@drawable/ic_trashcan"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
11
src/musikdroid/app/src/main/res/menu/edit_playlist_menu.xml
Normal file
11
src/musikdroid/app/src/main/res/menu/edit_playlist_menu.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
app:showAsAction="always"
|
||||
android:title="@string/button_save"/>
|
||||
|
||||
</menu>
|
@ -4,6 +4,10 @@
|
||||
android:id="@+id/menu_playlist_play"
|
||||
android:title="@string/menu_play_playlist"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_playlist_edit"
|
||||
android:title="@string/menu_edit_playlist"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_playlist_rename"
|
||||
android:title="@string/menu_rename_playlist"/>
|
||||
|
@ -78,6 +78,7 @@
|
||||
<string name="menu_show_genres">genres</string>
|
||||
<string name="menu_delete_playlist">delete</string>
|
||||
<string name="menu_rename_playlist">rename</string>
|
||||
<string name="menu_edit_playlist">edit</string>
|
||||
<string name="menu_play_playlist">play now</string>
|
||||
<string name="unknown_value"><unknown></string>
|
||||
<string name="snackbar_streaming_enabled">switched to streaming mode</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user