music: add indexer service
Add a dedicated service towards the loading of the music library. This new service was created for two reasons: 1. Music loading is slow and resource-intensive, so putting it on the ViewModel layer just didn't seem right and made it vulnerable to the OS simply stopping the loading process. 2. For automatic rescanning [#72], there must be something watching the music library and waiting for a change in the background. This would require a service as that is probably the least insane way to do that kind of background work. I have no garuntees how viable the service might be. If anything, it might be halted by some insane android restriction or issue that makes it more or less impossible to use for most apps, and I will have to largely drop truly automatic rescanning.
This commit is contained in:
parent
e3708bf5f5
commit
08caa01dca
16 changed files with 233 additions and 59 deletions
|
@ -67,6 +67,13 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".music.IndexerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
<service
|
||||
android:name=".playback.system.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
|
@ -74,6 +81,7 @@
|
|||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
|
||||
<!--
|
||||
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||
See the class for more info.
|
||||
|
|
|
@ -52,8 +52,10 @@ object IntegerTable {
|
|||
/** QueueSongViewHolder */
|
||||
const val ITEM_TYPE_QUEUE_SONG = 0xA00E
|
||||
|
||||
/** "Music playback" Notification code */
|
||||
/** "Music playback" notification code */
|
||||
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
||||
/** "Music loading" notification code */
|
||||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||
/** Intent request code */
|
||||
const val REQUEST_CODE = 0xA0C0
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.view.updatePadding
|
||||
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
||||
import org.oxycblt.auxio.music.IndexerService
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
|
@ -66,6 +67,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
startService(Intent(this, IndexerService::class.java))
|
||||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
||||
// If we have a file URI already, open it. Otherwise, restore the playback state.
|
||||
|
|
|
@ -70,11 +70,6 @@ class MainFragment : ViewBindingFragment<FragmentMainBinding>() {
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
// Initialize music loading. Do it here so that it shows on every fragment that this
|
||||
// one contains.
|
||||
// TODO: Move this to a service [automatic rescanning]
|
||||
musicModel.index(requireContext())
|
||||
|
||||
launch { navModel.mainNavigationAction.collect(::handleMainNavigation) }
|
||||
launch { navModel.exploreNavigationItem.collect(::handleExploreNavigation) }
|
||||
launch { playbackModel.song.collect(::updateSong) }
|
||||
|
|
|
@ -81,7 +81,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
// Build the permission launcher here as you can only do it in onCreateView/onCreate
|
||||
storagePermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
indexerModel.reindex(requireContext())
|
||||
indexerModel.reindex()
|
||||
}
|
||||
|
||||
binding.homeToolbar.apply {
|
||||
|
@ -287,7 +287,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
binding.homeLoadingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = getString(R.string.lbl_retry)
|
||||
setOnClickListener { indexerModel.reindex(requireContext()) }
|
||||
setOnClickListener { indexerModel.reindex() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoMusic -> {
|
||||
|
@ -296,7 +296,7 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
binding.homeLoadingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = getString(R.string.lbl_retry)
|
||||
setOnClickListener { indexerModel.reindex(requireContext()) }
|
||||
setOnClickListener { indexerModel.reindex() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoPerms -> {
|
||||
|
|
|
@ -24,7 +24,6 @@ import android.database.Cursor
|
|||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.oxycblt.auxio.music.backend.Api21MediaStoreBackend
|
||||
import org.oxycblt.auxio.music.backend.Api30MediaStoreBackend
|
||||
|
@ -77,6 +76,7 @@ class Indexer {
|
|||
|
||||
if (notGranted) {
|
||||
emitState(State.Complete(Response.NoPerms), generation)
|
||||
return
|
||||
}
|
||||
|
||||
val response =
|
||||
|
@ -101,6 +101,12 @@ class Indexer {
|
|||
emitState(State.Complete(response), generation)
|
||||
}
|
||||
|
||||
fun requestReindex() {
|
||||
for (callback in callbacks) {
|
||||
callback.onRequestReindex()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Cancel" the last job by making it unable to send further state updates. This should be
|
||||
* called if an object that called [index] is about to be destroyed and thus will have it's task
|
||||
|
@ -313,6 +319,7 @@ class Indexer {
|
|||
|
||||
interface Callback {
|
||||
fun onIndexerStateChanged(state: State?)
|
||||
fun onRequestReindex() {}
|
||||
}
|
||||
|
||||
/** Represents a backend that metadata can be extracted from. */
|
||||
|
|
172
app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt
Normal file
172
app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt
Normal file
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getSystemServiceSafe
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* A [Service] that handles the music loading process.
|
||||
*
|
||||
* Loading music is actually somewhat time-consuming, to the point where it's likely better suited
|
||||
* to a service that is less likely to be
|
||||
*
|
||||
* TODO: Rename all instances of loading in-app with indexing
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class IndexerService : Service(), Indexer.Callback {
|
||||
private val indexer = Indexer.getInstance()
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val indexScope = CoroutineScope(serviceJob + Dispatchers.Default)
|
||||
|
||||
private var isForeground = false
|
||||
private lateinit var notification: IndexerNotification
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
notification = IndexerNotification(this)
|
||||
|
||||
indexer.addCallback(this)
|
||||
if (musicStore.library == null) {
|
||||
logD("No library present, loading music now")
|
||||
onRequestReindex()
|
||||
}
|
||||
|
||||
logD("Service created.")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_NOT_STICKY
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
stopForeground(true)
|
||||
isForeground = false
|
||||
|
||||
indexer.removeCallback(this)
|
||||
indexer.cancelLast()
|
||||
serviceJob.cancel()
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
when (state) {
|
||||
is Indexer.State.Complete -> {
|
||||
if (state.response is Indexer.Response.Ok) {
|
||||
// Load was completed successfully, so apply the new library.
|
||||
musicStore.library = state.response.library
|
||||
}
|
||||
|
||||
// On errors, while we would want to show a notification that displays the
|
||||
// error, in practice that comes into conflict with the upcoming Android 13
|
||||
// notification permission, and there is no point implementing permission
|
||||
// on-boarding for such when it will only be used for this.
|
||||
|
||||
// Note that we don't stop the service here, as (in the future)
|
||||
// this service will be used to reload music and observe the music
|
||||
// database.
|
||||
stopForegroundSession()
|
||||
}
|
||||
is Indexer.State.Query,
|
||||
is Indexer.State.Loading -> {
|
||||
// We are loading, so we want to the enter the foreground state so that android
|
||||
// does not kill our app. Note that while we would prefer to display the current
|
||||
// loading progress, updates tend to be too rapid-fire for it too work well.
|
||||
startForegroundSession()
|
||||
}
|
||||
null -> {
|
||||
// Null is the indeterminate state that occurs on app startup or after
|
||||
// the cancellation of a load, so in that case we want to stop foreground
|
||||
// since (technically) nothing is loading.
|
||||
stopForegroundSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestReindex() {
|
||||
indexScope.launch { indexer.index(this@IndexerService) }
|
||||
}
|
||||
|
||||
private fun startForegroundSession() {
|
||||
if (!isForeground) {
|
||||
logD("Starting foreground service")
|
||||
startForeground(IntegerTable.INDEXER_NOTIFICATION_CODE, notification.build())
|
||||
isForeground = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopForegroundSession() {
|
||||
if (isForeground) {
|
||||
stopForeground(true)
|
||||
isForeground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class IndexerNotification(context: Context) :
|
||||
NotificationCompat.Builder(context, CHANNEL_ID) {
|
||||
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.info_indexer_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
setSmallIcon(R.drawable.ic_indexer)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
setShowWhen(false)
|
||||
setSilent(true)
|
||||
setContentIntent(context.newMainPendingIntent())
|
||||
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
setContentTitle(context.getString(R.string.info_indexer_channel_name))
|
||||
setContentText(context.getString(R.string.lbl_loading))
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.INDEXER"
|
||||
}
|
||||
}
|
|
@ -17,18 +17,13 @@
|
|||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/** A ViewModel representing the current music indexing state. */
|
||||
class IndexerViewModel : ViewModel(), Indexer.Callback {
|
||||
private val indexer = Indexer.getInstance()
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val _state = MutableStateFlow<Indexer.State?>(null)
|
||||
val state: StateFlow<Indexer.State?> = _state
|
||||
|
@ -37,40 +32,15 @@ class IndexerViewModel : ViewModel(), Indexer.Callback {
|
|||
indexer.addCallback(this)
|
||||
}
|
||||
|
||||
/** Initiate the indexing process. */
|
||||
fun index(context: Context) {
|
||||
if (state.value != null) {
|
||||
logD("Loader is already loading/is completed, not reloading")
|
||||
return
|
||||
}
|
||||
|
||||
indexImpl(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the music library. Note that this call will result in unexpected behavior in the case
|
||||
* that music is reloaded after a loading process has already exceeded.
|
||||
*/
|
||||
fun reindex(context: Context) {
|
||||
logD("Reloading music library")
|
||||
indexImpl(context)
|
||||
}
|
||||
|
||||
private fun indexImpl(context: Context) {
|
||||
viewModelScope.launch { indexer.index(context) }
|
||||
fun reindex() {
|
||||
indexer.requestReindex()
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
_state.value = state
|
||||
|
||||
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
|
||||
musicStore.library = state.response.library
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
indexer.cancelLast()
|
||||
indexer.removeCallback(this)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,8 +24,8 @@ import org.oxycblt.auxio.music.backend.useQuery
|
|||
import org.oxycblt.auxio.util.contentResolverSafe
|
||||
|
||||
/**
|
||||
* The main storage for music items. Getting an instance of this object is more complicated as it
|
||||
* loads asynchronously. See the companion object for more.
|
||||
* The main storage for music items. The items themselves are located in a [Library], however this
|
||||
* might not be available at all times.
|
||||
*
|
||||
* TODO: Add automatic rescanning [major change]
|
||||
* @author OxygenCobalt
|
||||
|
@ -88,6 +88,7 @@ class MusicStore private constructor() {
|
|||
companion object {
|
||||
@Volatile private var INSTANCE: MusicStore? = null
|
||||
|
||||
/** Get the process-level instance of [MusicStore] */
|
||||
fun getInstance(): MusicStore {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
|
|
|
@ -121,6 +121,7 @@ class PlaybackPanelFragment :
|
|||
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
||||
binding.playbackSong.isSelected = false
|
||||
binding.playbackSeekBar.callback = null
|
||||
queueItem = null
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
|
|
|
@ -40,9 +40,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
/**
|
||||
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
|
||||
*
|
||||
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this
|
||||
* **PLEASE Use this instead of [PlaybackStateManager], UIs are extremely volatile and this
|
||||
* provides an interface that properly sanitizes input and abstracts functions unlike the master
|
||||
* class.**
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore.Callback {
|
||||
|
|
|
@ -35,8 +35,8 @@ import org.oxycblt.auxio.music.MusicParent
|
|||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.util.getSystemServiceSafe
|
||||
import org.oxycblt.auxio.util.newBroadcastIntent
|
||||
import org.oxycblt.auxio.util.newMainIntent
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* The unified notification for [PlaybackService]. Due to the nature of how this notification is
|
||||
|
@ -68,7 +68,7 @@ class NotificationComponent(
|
|||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
setShowWhen(false)
|
||||
setSilent(true)
|
||||
setContentIntent(context.newMainIntent())
|
||||
setContentIntent(context.newMainPendingIntent())
|
||||
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
|
||||
addAction(buildRepeatAction(context, RepeatMode.NONE))
|
||||
|
@ -182,7 +182,7 @@ class NotificationComponent(
|
|||
): NotificationCompat.Action {
|
||||
val action =
|
||||
NotificationCompat.Action.Builder(
|
||||
iconRes, actionName, context.newBroadcastIntent(actionName))
|
||||
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
||||
|
||||
return action.build()
|
||||
}
|
||||
|
|
|
@ -222,7 +222,7 @@ fun Context.showToast(@StringRes str: Int) {
|
|||
}
|
||||
|
||||
/** Create a [PendingIntent] that leads to Auxio's [MainActivity] */
|
||||
fun Context.newMainIntent(): PendingIntent =
|
||||
fun Context.newMainPendingIntent(): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
IntegerTable.REQUEST_CODE,
|
||||
|
@ -230,7 +230,7 @@ fun Context.newMainIntent(): PendingIntent =
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||
|
||||
/** Create a broadcast [PendingIntent] */
|
||||
fun Context.newBroadcastIntent(what: String): PendingIntent =
|
||||
fun Context.newBroadcastPendingIntent(what: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
IntegerTable.REQUEST_CODE,
|
||||
|
|
|
@ -23,8 +23,8 @@ import androidx.annotation.LayoutRes
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.util.newBroadcastIntent
|
||||
import org.oxycblt.auxio.util.newMainIntent
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* The default widget is displayed whenever there is no music playing. It just shows the message "No
|
||||
|
@ -73,7 +73,7 @@ fun createLargeWidget(context: Context, state: WidgetComponent.WidgetState): Rem
|
|||
|
||||
private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, layout)
|
||||
views.setOnClickPendingIntent(android.R.id.background, context.newMainIntent())
|
||||
views.setOnClickPendingIntent(android.R.id.background, context.newMainPendingIntent())
|
||||
return views
|
||||
}
|
||||
|
||||
|
@ -111,7 +111,8 @@ private fun RemoteViews.applyPlayPauseControls(
|
|||
state: WidgetComponent.WidgetState
|
||||
): RemoteViews {
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_play_pause, context.newBroadcastIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
R.id.widget_play_pause,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
|
||||
setImageViewResource(
|
||||
R.id.widget_play_pause,
|
||||
|
@ -131,10 +132,10 @@ private fun RemoteViews.applyBasicControls(
|
|||
applyPlayPauseControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_prev, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
R.id.widget_skip_prev, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_next, context.newBroadcastIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
R.id.widget_skip_next, context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -146,10 +147,12 @@ private fun RemoteViews.applyFullControls(
|
|||
applyBasicControls(context, state)
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_repeat, context.newBroadcastIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
|
||||
R.id.widget_repeat,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
|
||||
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_shuffle, context.newBroadcastIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
|
||||
R.id.widget_shuffle,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
|
||||
|
||||
// Like notifications, use the remote variants of icons since we really don't want to hack
|
||||
// indicators.
|
||||
|
|
11
app/src/main/res/drawable/ic_indexer.xml
Normal file
11
app/src/main/res/drawable/ic_indexer.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4.2,5l-0.7,1.9L17.6,12L3,12v8h18v-8.86L4.2,5zM7,17L5,17v-2h2v2zM19,17L9,17v-2h10v2z"/>
|
||||
</vector>
|
|
@ -3,6 +3,7 @@
|
|||
<!-- Info namespace | App labels -->
|
||||
<string name="info_app_desc">A simple, rational music player for android.</string>
|
||||
<string name="info_playback_channel_name">Music Playback</string>
|
||||
<string name="info_indexer_channel_name">Music Loading</string>
|
||||
<string name="info_widget_desc">View and control music playback</string>
|
||||
|
||||
<!-- Label Namespace | Static Labels -->
|
||||
|
|
Loading…
Reference in a new issue