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:
* 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

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
the project is currently built using `Android Studio 3.0 Canary 6`
the project is currently built using `Android Studio 3.0.1`
# attribution

View File

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

View File

@ -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() {
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?) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -410,10 +410,10 @@ 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() {
private val updateTimeRunnable = Runnable {
val duration = playback.service.duration
val current: Double = if (seekbarValue == -1) playback.service.currentTime else seekbarValue.toDouble()
@ -434,7 +434,6 @@ class MainActivity : BaseActivity() {
scheduleUpdateTime(false)
}
}
private val muteListener = { _: CompoundButton, b: Boolean ->
if (b != playback.service.muted) {

View File

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

View File

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

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

View File

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

View File

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

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> =
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)

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: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"/>

View File

@ -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">&lt;unknown&gt;</string>
<string name="snackbar_streaming_enabled">switched to streaming mode</string>