Incremental work to support playlist editing.

This commit is contained in:
casey langen 2017-12-01 00:14:06 -08:00
parent fc99911ad1
commit 78c57b8f10
29 changed files with 566 additions and 65 deletions

View File

@ -7,7 +7,8 @@ musikcube:
musikdroid: 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 * album art is now displayed in album rows when browsing
* context menus on most screens with the ability to switch between related * context menus on most screens with the ability to switch between related
content (e.g. albums by this artist, artists in this genre, etc) content (e.g. albums by this artist, artists in this genre, etc)
@ -22,7 +23,6 @@ musikdroid:
* updated Glide from v3 -> v4 * updated Glide from v3 -> v4
* updated to Android Studio 3.0.1 and related tooling * updated to Android Studio 3.0.1 and related tooling
sdk: sdk:
* removed all Destroy() methods, standardized on Release() across the board * removed all Destroy() methods, standardized on Release() across the board

View File

@ -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 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 # attribution

View File

@ -51,6 +51,11 @@
</activity> </activity>
<activity android:name=".ui.tracks.activity.EditPlaylistActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
</activity>
<activity <activity
android:name="io.casey.musikcube.remote.ui.category.activity.CategoryBrowseActivity" android:name="io.casey.musikcube.remote.ui.category.activity.CategoryBrowseActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"

View File

@ -6,40 +6,58 @@ import android.os.Looper
import com.uacf.taskrunner.Runner import com.uacf.taskrunner.Runner
import com.uacf.taskrunner.Task import com.uacf.taskrunner.Task
import io.casey.musikcube.remote.Application 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 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() val id: Long = nextId.incrementAndGet()
private val publisher by lazy { createSubject() }
interface Provider { interface Provider {
fun <T: ViewModel<*>> createViewModel(): T? fun <T: ViewModel<*>> createViewModel(): T?
} }
protected var listener: ListenerT? = null protected var listener: T? = null
private set private set
fun onPause() { open fun onPause() {
} }
fun onResume() { open fun onResume() {
} }
fun onDestroy() { open fun onDestroy() {
listener = null listener = null
handler.postDelayed(cleanup, cleanupDelayMs) handler.postDelayed(cleanup, cleanupDelayMs)
} }
fun observe(listener: ListenerT) { open fun onCleanup() {
this.listener = listener
}
fun observe(): Observable<T> {
return publisher
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(AndroidSchedulers.mainThread())
} }
val context: Context = Application.instance!! val context: Context = Application.instance!!
internal val cleanup = object: Runnable { internal val cleanup = Runnable {
override fun run() {
listener = null listener = null
idToInstance.remove(id) 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?) { override fun onTaskError(name: String?, id: Long, task: Task<*, *>?, error: Throwable?) {

View File

@ -373,7 +373,6 @@ class RemotePlaybackService : IPlaybackService {
override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() { override val playlistQueryFactory: TrackListSlidingWindow.QueryFactory = object : TrackListSlidingWindow.QueryFactory() {
override fun count(): Observable<Int> = dataProvider.getPlayQueueTracksCount() 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 page(offset: Int, limit: Int): Observable<List<ITrack>> = dataProvider.getPlayQueueTracks(limit, offset)
override fun offline(): Boolean = false override fun offline(): Boolean = false
} }

View File

@ -774,20 +774,6 @@ class StreamingPlaybackService(context: Context) : IPlaybackService {
return null 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>>? { override fun page(offset: Int, limit: Int): Observable<List<ITrack>>? {
val params = params val params = params
if (params != null) { if (params != null) {

View File

@ -77,6 +77,7 @@ class Messages {
val ID = "id" val ID = "id"
val COUNT = "count" val COUNT = "count"
val COUNT_ONLY = "count_only" val COUNT_ONLY = "count_only"
val IDS_ONLY = "ids_only"
val OFFSET = "offset" val OFFSET = "offset"
val LIMIT = "limit" val LIMIT = "limit"
val INDEX = "index" val INDEX = "index"

View File

@ -330,6 +330,8 @@ class WebSocketService constructor(private val context: Context) {
subject.onError(ex) subject.onError(ex)
} }
subject.doOnDispose { cancelMessage(mrd.id) }
if (!intercepted) { if (!intercepted) {
socket?.sendText(message.toString()) socket?.sendText(message.toString())
} }

View File

@ -22,6 +22,7 @@ interface IDataProvider {
fun getTracks(externalIds: Set<String>): Observable<Map<String, ITrack>> fun getTracks(externalIds: Set<String>): Observable<Map<String, ITrack>>
fun getTrackCountByCategory(category: String, id: Long, filter: String = ""): Observable<Int> 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, filter: String = ""): Observable<List<ITrack>>
fun getTracksByCategory(category: String, id: Long, limit: Int, offset: Int, 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, categoryType: String = "", categoryId: Long = -1, filter: String = ""): Observable<Long>
fun createPlaylist(playlistName: String, tracks: List<ITrack> = ArrayList()): Observable<Long> fun createPlaylist(playlistName: String, tracks: List<ITrack> = ArrayList()): Observable<Long>
fun createPlaylistWithExternalIds(playlistName: String, externalIds: List<String> = 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, 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, tracks: List<ITrack> = ArrayList(), offset: Long = -1): Observable<Boolean>
fun appendToPlaylist(playlistId: Long, categoryValue: ICategoryValue): Observable<Boolean> fun appendToPlaylist(playlistId: Long, categoryValue: ICategoryValue): Observable<Boolean>

View File

@ -116,6 +116,21 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
.observeOn(AndroidSchedulers.mainThread()) .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>> = override fun getTracksByCategory(category: String, id: Long, filter: String): Observable<List<ITrack>> =
getTracksByCategory(category, id, -1, -1, filter) 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> { override fun createPlaylistWithExternalIds(playlistName: String, externalIds: List<String>): Observable<Long> {
if (playlistName.isBlank()) { if (playlistName.isBlank()) {
return Observable.just(0) return Observable.just(-1L)
} }
val jsonArray = JSONArray() val jsonArray = JSONArray()
@ -258,6 +273,25 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
.observeOn(AndroidSchedulers.mainThread()) .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> { override fun appendToPlaylist(playlistId: Long, categoryType: String, categoryId: Long, filter: String, offset: Long): Observable<Boolean> {
val message = SocketMessage.Builder val message = SocketMessage.Builder
.request(Messages.Request.AppendToPlaylist) .request(Messages.Request.AppendToPlaylist)
@ -461,6 +495,15 @@ class RemoteDataProvider(private val service: WebSocketService) : IDataProvider
return Observable.just(albums) 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> { private fun toCount(message: SocketMessage): Observable<Int> {
return Observable.just(message.getIntOption(Messages.Key.COUNT, 0)) return Observable.just(message.getIntOption(Messages.Key.COUNT, 0))
} }

View File

@ -410,10 +410,10 @@ class MainActivity : BaseActivity() {
private fun scheduleUpdateTime(immediate: Boolean) { private fun scheduleUpdateTime(immediate: Boolean) {
handler.removeCallbacks(updateTimeRunnable) handler.removeCallbacks(updateTimeRunnable)
handler.postDelayed(updateTimeRunnable, (if (immediate) 0 else 1000).toLong()) handler.postDelayed(updateTimeRunnable, (if (immediate) 0 else 1000).toLong())
handler.removeCallbacks(updateTimeRunnable)
} }
private val updateTimeRunnable = object: Runnable { private val updateTimeRunnable = Runnable {
override fun run() {
val duration = playback.service.duration val duration = playback.service.duration
val current: Double = if (seekbarValue == -1) playback.service.currentTime else seekbarValue.toDouble() val current: Double = if (seekbarValue == -1) playback.service.currentTime else seekbarValue.toDouble()
@ -434,7 +434,6 @@ class MainActivity : BaseActivity() {
scheduleUpdateTime(false) scheduleUpdateTime(false)
} }
}
private val muteListener = { _: CompoundButton, b: Boolean -> private val muteListener = { _: CompoundButton, b: Boolean ->
if (b != playback.service.muted) { if (b != playback.service.muted) {

View File

@ -23,7 +23,7 @@ class PlayQueueAdapter(val tracks: TrackListSlidingWindow,
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context) 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) val action = view.findViewById<View>(R.id.action)
view.setOnClickListener{ v -> listener.onItemClicked(v.tag as Int) } view.setOnClickListener{ v -> listener.onItemClicked(v.tag as Int) }
action.setOnClickListener{ v -> listener.onActionClicked(v, v.tag as ITrack) } action.setOnClickListener{ v -> listener.onActionClicked(v, v.tag as ITrack) }

View File

@ -28,15 +28,12 @@ import io.casey.musikcube.remote.util.Strings
val EXTRA_ACTIVITY_TITLE = "extra_title" val EXTRA_ACTIVITY_TITLE = "extra_title"
fun AppCompatActivity.setupDefaultRecyclerView( fun AppCompatActivity.setupDefaultRecyclerView(
recyclerView: RecyclerView, recyclerView: RecyclerView, adapter: RecyclerView.Adapter<*>)
adapter: RecyclerView.Adapter<*>) { {
val layoutManager = LinearLayoutManager(this) val layoutManager = LinearLayoutManager(this)
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter recyclerView.adapter = adapter
val dividerItemDecoration = DividerItemDecoration(this, layoutManager.orientation)
recyclerView.addItemDecoration(dividerItemDecoration) recyclerView.addItemDecoration(dividerItemDecoration)
} }

View File

@ -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.showKeyboard
import io.casey.musikcube.remote.ui.shared.extension.showSnackbar import io.casey.musikcube.remote.ui.shared.extension.showSnackbar
import io.casey.musikcube.remote.ui.shared.fragment.BaseDialogFragment 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.casey.musikcube.remote.ui.tracks.activity.TrackListActivity
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.rxkotlin.subscribeBy import io.reactivex.rxkotlin.subscribeBy
@ -233,6 +234,9 @@ class ItemContextMenuMixin(private val activity: AppCompatActivity,
R.id.menu_playlist_delete -> { R.id.menu_playlist_delete -> {
ConfirmDeletePlaylistDialog.show(activity, this, playlistName, playlistId) ConfirmDeletePlaylistDialog.show(activity, this, playlistName, playlistId)
} }
R.id.menu_playlist_edit -> {
activity.startActivity(EditPlaylistActivity.getStartIntent(activity, playlistId))
}
R.id.menu_playlist_rename -> { R.id.menu_playlist_rename -> {
EnterPlaylistNameDialog.showForRename(activity, this, playlistName, playlistId) EnterPlaylistNameDialog.showForRename(activity, this, playlistName, playlistId)
} }

View File

@ -7,7 +7,12 @@ import io.casey.musikcube.remote.framework.ViewModel
class ViewModelMixin(private val provider: ViewModel.Provider): MixinBase() { class ViewModelMixin(private val provider: ViewModel.Provider): MixinBase() {
private var viewModel: ViewModel<*>? = null 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) { override fun onCreate(bundle: Bundle) {
super.onCreate(bundle) super.onCreate(bundle)

View File

@ -40,7 +40,6 @@ class TrackListSlidingWindow(private val recyclerView: FastScrollRecyclerView,
abstract class QueryFactory { abstract class QueryFactory {
abstract fun count(): Observable<Int>? abstract fun count(): Observable<Int>?
abstract fun all(): Observable<List<ITrack>>?
abstract fun page(offset: Int, limit: Int): Observable<List<ITrack>>? abstract fun page(offset: Int, limit: Int): Observable<List<ITrack>>?
abstract fun offline(): Boolean abstract fun offline(): Boolean
} }

View File

@ -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)
}
}
}

View File

@ -185,9 +185,6 @@ class TrackListActivity : BaseActivity(), Filterable {
override fun count(): Observable<Int> = override fun count(): Observable<Int> =
data.provider.getTrackCountByCategory(categoryType ?: "", categoryId, lastFilter) 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>> = override fun page(offset: Int, limit: Int): Observable<List<ITrack>> =
data.provider.getTracksByCategory(categoryType ?: "", categoryId, limit, offset, lastFilter) data.provider.getTracksByCategory(categoryType ?: "", categoryId, limit, offset, lastFilter)
@ -201,9 +198,6 @@ class TrackListActivity : BaseActivity(), Filterable {
override fun count(): Observable<Int> = override fun count(): Observable<Int> =
data.provider.getTrackCount(lastFilter) data.provider.getTrackCount(lastFilter)
override fun all(): Observable<List<ITrack>>? =
data.provider.getTracks(lastFilter)
override fun page(offset: Int, limit: Int): Observable<List<ITrack>> = override fun page(offset: Int, limit: Int): Observable<List<ITrack>> =
data.provider.getTracks(limit, offset, lastFilter) data.provider.getTracks(limit, offset, lastFilter)

View File

@ -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, "-")
}
}
}

View File

@ -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
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -4,6 +4,10 @@
android:id="@+id/menu_playlist_play" android:id="@+id/menu_playlist_play"
android:title="@string/menu_play_playlist"/> android:title="@string/menu_play_playlist"/>
<item
android:id="@+id/menu_playlist_edit"
android:title="@string/menu_edit_playlist"/>
<item <item
android:id="@+id/menu_playlist_rename" android:id="@+id/menu_playlist_rename"
android:title="@string/menu_rename_playlist"/> android:title="@string/menu_rename_playlist"/>

View File

@ -78,6 +78,7 @@
<string name="menu_show_genres">genres</string> <string name="menu_show_genres">genres</string>
<string name="menu_delete_playlist">delete</string> <string name="menu_delete_playlist">delete</string>
<string name="menu_rename_playlist">rename</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="menu_play_playlist">play now</string>
<string name="unknown_value">&lt;unknown&gt;</string> <string name="unknown_value">&lt;unknown&gt;</string>
<string name="snackbar_streaming_enabled">switched to streaming mode</string> <string name="snackbar_streaming_enabled">switched to streaming mode</string>