Created a media player control widget (#1005)

* Added initial media player widget code.  Has some work to do

* ktLint Fixes and corrected TODOs

* Finished TODO: Media image fetch and update

* Corrected rewind/fastforward functionality so it actually seeks

* Made requested minor changes

* Additional minor requested changes

* Appeasing the mighty ktlint

* Added integration GET request for single entity instead of requesting all entities and parsing results

* Corrected dark mode widget icons

* Fix merge conflicts and migration.

* Added toast notification for entity fetch failure

Co-authored-by: Basset, Justin <Bassett.JustinT@gmail.com>
This commit is contained in:
Kevin T. Berstene 2020-10-13 12:13:50 -04:00 committed by GitHub
parent cce1608bf0
commit 51072b9e0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1038 additions and 7 deletions

View file

@ -133,6 +133,7 @@ dependencies {
implementation(Config.Dependency.Misc.jackson)
implementation(Config.Dependency.Square.okhttp)
implementation(Config.Dependency.Square.picasso)
"fullImplementation"(Config.Dependency.Play.location)
"fullImplementation"(Config.Dependency.Firebase.core)

View file

@ -78,6 +78,23 @@
android:resource="@xml/entity_widget_info" />
</receiver>
<receiver android:name=".widgets.media_player_controls.MediaPlayerControlsWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.RECEIVE_DATA" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.UPDATE_MEDIA_IMAGE" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_PREV_TRACK" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_REWIND" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_PLAYPAUSE" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_FASTFORWARD" />
<action android:name="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_NEXT_TRACK" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/media_player_control_widget_info" />
</receiver>
<receiver android:name=".widgets.template.TemplateWidget" android:label="Template Widget">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
@ -107,7 +124,11 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".widgets.media_player_controls.MediaPlayerControlsWidgetConfigureActivity">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".widgets.template.TemplateWidgetConfigureActivity">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />

View file

@ -25,6 +25,8 @@ import io.homeassistant.companion.android.database.sensor.SensorDao
import io.homeassistant.companion.android.database.sensor.Setting
import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity
import io.homeassistant.companion.android.database.widget.StaticWidgetDao
import io.homeassistant.companion.android.database.widget.StaticWidgetEntity
import io.homeassistant.companion.android.database.widget.TemplateWidgetDao
@ -38,15 +40,17 @@ import kotlinx.coroutines.runBlocking
Sensor::class,
Setting::class,
ButtonWidgetEntity::class,
MediaPlayerControlsWidgetEntity::class,
StaticWidgetEntity::class,
TemplateWidgetEntity::class
],
version = 11
version = 12
)
abstract class AppDatabase : RoomDatabase() {
abstract fun authenticationDao(): AuthenticationDao
abstract fun sensorDao(): SensorDao
abstract fun buttonWidgetDao(): ButtonWidgetDao
abstract fun mediaPlayCtrlWidgetDao(): MediaPlayerControlsWidgetDao
abstract fun staticWidgetDao(): StaticWidgetDao
abstract fun templateWidgetDao(): TemplateWidgetDao
@ -83,7 +87,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
MIGRATION_10_11,
MIGRATION_11_12
)
.fallbackToDestructiveMigration()
.build()
@ -254,6 +259,12 @@ abstract class AppDatabase : RoomDatabase() {
}
}
private val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `mediaplayctrls_widgets` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, `label` TEXT, `showSkip` INTEGER NOT NULL, `showSeek` INTEGER NOT NULL, PRIMARY KEY(`id`))")
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

View file

@ -0,0 +1,23 @@
package io.homeassistant.companion.android.database.widget
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
@Dao
interface MediaPlayerControlsWidgetDao {
@Query("SELECT * FROM mediaplayctrls_widgets WHERE id = :id")
fun get(id: Int): MediaPlayerControlsWidgetEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun add(mediaPlayCtrlWidgetEntity: MediaPlayerControlsWidgetEntity)
@Update
fun update(mediaPlayCtrlWidgetEntity: MediaPlayerControlsWidgetEntity)
@Query("DELETE FROM mediaplayctrls_widgets WHERE id = :id")
fun delete(id: Int)
}

View file

@ -0,0 +1,19 @@
package io.homeassistant.companion.android.database.widget
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "mediaplayctrls_widgets")
data class MediaPlayerControlsWidgetEntity(
@PrimaryKey
val id: Int,
@ColumnInfo(name = "entityId")
val entityId: String,
@ColumnInfo(name = "label")
val label: String?,
@ColumnInfo(name = "showSkip")
val showSkip: Boolean,
@ColumnInfo(name = "showSeek")
val showSeek: Boolean
)

View file

@ -6,6 +6,8 @@ import io.homeassistant.companion.android.widgets.button.ButtonWidget
import io.homeassistant.companion.android.widgets.button.ButtonWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.entity.EntityWidget
import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget
import io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.template.TemplateWidget
import io.homeassistant.companion.android.widgets.template.TemplateWidgetConfigureActivity
@ -13,14 +15,14 @@ import io.homeassistant.companion.android.widgets.template.TemplateWidgetConfigu
interface ProviderComponent {
fun inject(receiver: ButtonWidget)
fun inject(activity: ButtonWidgetConfigureActivity)
fun inject(receiver: EntityWidget)
fun inject(receiver: MediaPlayerControlsWidget)
fun inject(activity: MediaPlayerControlsWidgetConfigureActivity)
fun inject(receiver: EntityWidget)
fun inject(activity: EntityWidgetConfigureActivity)
fun inject(receiver: TemplateWidget)
fun inject(activity: TemplateWidgetConfigureActivity)
}

View file

@ -0,0 +1,501 @@
package io.homeassistant.companion.android.widgets.media_player_controls
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import android.widget.Toast
import com.squareup.picasso.Picasso
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity
import io.homeassistant.companion.android.widgets.DaggerProviderComponent
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class MediaPlayerControlsWidget : AppWidgetProvider() {
companion object {
private const val TAG = "MediaPlayCtrlsWidget"
internal const val RECEIVE_DATA =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.RECEIVE_DATA"
internal const val UPDATE_MEDIA_IMAGE =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.UPDATE_MEDIA_IMAGE"
internal const val CALL_PREV_TRACK =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_PREV_TRACK"
internal const val CALL_REWIND =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_REWIND"
internal const val CALL_PLAYPAUSE =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_PLAYPAUSE"
internal const val CALL_FASTFORWARD =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_FASTFORWARD"
internal const val CALL_NEXT_TRACK =
"io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidget.CALL_NEXT_TRACK"
internal const val EXTRA_ENTITY_ID = "EXTRA_ENTITY_ID"
internal const val EXTRA_LABEL = "EXTRA_LABEL"
internal const val EXTRA_SHOW_SKIP = "EXTRA_INCLUDE_SKIP"
internal const val EXTRA_SHOW_SEEK = "EXTRA_INCLUDE_SEEK"
}
@Inject
lateinit var integrationUseCase: IntegrationRepository
lateinit var mediaPlayCtrlWidgetDao: MediaPlayerControlsWidgetDao
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
mediaPlayCtrlWidgetDao = AppDatabase.getInstance(context).mediaPlayCtrlWidgetDao()
// There may be multiple widgets active, so update all of them
appWidgetIds.forEach { appWidgetId ->
updateAppWidget(
context,
appWidgetId,
appWidgetManager
)
}
}
private fun updateAppWidget(
context: Context,
appWidgetId: Int,
appWidgetManager: AppWidgetManager = AppWidgetManager.getInstance(context)
) {
mainScope.launch {
val views = getWidgetRemoteViews(context, appWidgetId)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews {
val updateMediaIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = UPDATE_MEDIA_IMAGE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val prevTrackIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = CALL_PREV_TRACK
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val rewindIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = CALL_REWIND
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val playPauseIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = CALL_PLAYPAUSE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val fastForwardIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = CALL_FASTFORWARD
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val nextTrackIntent = Intent(context, MediaPlayerControlsWidget::class.java).apply {
action = CALL_NEXT_TRACK
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
return RemoteViews(context.packageName, R.layout.widget_media_controls).apply {
val widget = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (widget != null) {
val entityId: String = widget.entityId
val label: String? = widget.label
val showSkip: Boolean = widget.showSkip
val showSeek: Boolean = widget.showSeek
setTextViewText(
R.id.widgetLabel,
label ?: entityId
)
val entityPictureUrl = retrieveMediaPlayerImageUrl(context, entityId)
if (entityPictureUrl == null) {
setImageViewResource(
R.id.widgetMediaImage,
R.drawable.app_icon
)
setViewVisibility(
R.id.widgetMediaPlaceholder,
View.VISIBLE
)
setViewVisibility(
R.id.widgetMediaImage,
View.GONE
)
} else {
setViewVisibility(
R.id.widgetMediaImage,
View.VISIBLE
)
setViewVisibility(
R.id.widgetMediaPlaceholder,
View.GONE
)
Log.d(TAG, "Fetching media preview image")
Handler().post {
Picasso.get().load(entityPictureUrl).into(
this,
R.id.widgetMediaImage,
intArrayOf(appWidgetId)
)
Log.d(TAG, "Fetch and load complete")
}
}
setOnClickPendingIntent(
R.id.widgetMediaImage,
PendingIntent.getBroadcast(
context,
appWidgetId,
updateMediaIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
setOnClickPendingIntent(
R.id.widgetMediaPlaceholder,
PendingIntent.getBroadcast(
context,
appWidgetId,
updateMediaIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
setOnClickPendingIntent(
R.id.widgetPlayPauseButton,
PendingIntent.getBroadcast(
context,
appWidgetId,
playPauseIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
if (showSkip) {
setOnClickPendingIntent(
R.id.widgetPrevTrackButton,
PendingIntent.getBroadcast(
context,
appWidgetId,
prevTrackIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
setOnClickPendingIntent(
R.id.widgetNextTrackButton,
PendingIntent.getBroadcast(
context,
appWidgetId,
nextTrackIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
} else {
setViewVisibility(R.id.widgetPrevTrackButton, View.GONE)
setViewVisibility(R.id.widgetNextTrackButton, View.GONE)
}
if (showSeek) {
setOnClickPendingIntent(
R.id.widgetRewindButton,
PendingIntent.getBroadcast(
context,
appWidgetId,
rewindIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
setOnClickPendingIntent(
R.id.widgetFastForwardButton,
PendingIntent.getBroadcast(
context,
appWidgetId,
fastForwardIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
)
} else {
setViewVisibility(R.id.widgetRewindButton, View.GONE)
setViewVisibility(R.id.widgetFastForwardButton, View.GONE)
}
}
}
}
private suspend fun retrieveMediaPlayerImageUrl(context: Context, entityId: String): String? {
val entity: Entity<Map<String, Any>>
try {
entity = integrationUseCase.getEntity(entityId)
} catch (e: Exception) {
Log.d(TAG, "Failed to fetch entity or entity does not exist")
Toast.makeText(context, R.string.widget_entity_fetch_error, Toast.LENGTH_LONG).show()
return null
}
return entity.attributes["entity_picture"]?.toString()
}
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
Log.d(
TAG, "Broadcast received: " + System.lineSeparator() +
"Broadcast action: " + action + System.lineSeparator() +
"AppWidgetId: " + appWidgetId
)
ensureInjected(context)
mediaPlayCtrlWidgetDao = AppDatabase.getInstance(context).mediaPlayCtrlWidgetDao()
super.onReceive(context, intent)
when (action) {
RECEIVE_DATA -> saveEntityConfiguration(context, intent.extras, appWidgetId)
UPDATE_MEDIA_IMAGE -> updateAppWidget(context, appWidgetId)
CALL_PREV_TRACK -> callPreviousTrackService(context, appWidgetId)
CALL_REWIND -> callRewindService(context, appWidgetId)
CALL_PLAYPAUSE -> callPlayPauseService(context, appWidgetId)
CALL_FASTFORWARD -> callFastForwardService(context, appWidgetId)
CALL_NEXT_TRACK -> callNextTrackService(context, appWidgetId)
}
}
private fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) {
if (extras == null) return
val entitySelection: String? = extras.getString(EXTRA_ENTITY_ID)
val labelSelection: String? = extras.getString(EXTRA_LABEL)
val showSkip: Boolean? = extras.getBoolean(EXTRA_SHOW_SKIP)
val showSeek: Boolean? = extras.getBoolean(EXTRA_SHOW_SEEK)
if (entitySelection == null || showSkip == null || showSeek == null) {
Log.e(TAG, "Did not receive complete configuration data")
return
}
mainScope.launch {
Log.d(
TAG, "Saving service call config data:" + System.lineSeparator() +
"entity id: " + entitySelection + System.lineSeparator()
)
mediaPlayCtrlWidgetDao.add(
MediaPlayerControlsWidgetEntity(
appWidgetId,
entitySelection,
labelSelection,
showSkip,
showSeek
)
)
onUpdate(context, AppWidgetManager.getInstance(context), intArrayOf(appWidgetId))
}
}
private fun callPreviousTrackService(context: Context, appWidgetId: Int) {
mainScope.launch {
Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId")
val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (entity == null) {
Log.d(TAG, "Failed to retrieve media player entity")
return@launch
}
Log.d(
TAG, "Calling previous track service:" + System.lineSeparator() +
"entity id: " + entity.entityId + System.lineSeparator()
)
val domain = "media_player"
val service = "media_previous_track"
val serviceDataMap: HashMap<String, Any> = hashMapOf("entity_id" to entity.entityId)
integrationUseCase.callService(domain, service, serviceDataMap)
}
}
private fun callRewindService(context: Context, appWidgetId: Int) {
mainScope.launch {
Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId")
val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (entity == null) {
Log.d(TAG, "Failed to retrieve media player entity")
return@launch
}
Log.d(
TAG, "Calling rewind service:" + System.lineSeparator() +
"entity id: " + entity.entityId + System.lineSeparator()
)
val currentEntityInfo: Entity<Map<String, Any>>
try {
currentEntityInfo = integrationUseCase.getEntity(entity.entityId)
} catch (e: Exception) {
Log.d(TAG, "Failed to fetch entity or entity does not exist")
Toast.makeText(context, R.string.widget_entity_fetch_error, Toast.LENGTH_LONG).show()
return@launch
}
val fetchedAttributes = currentEntityInfo.attributes
val currentTime = fetchedAttributes["media_position"]?.toString()?.toDoubleOrNull()
if (currentTime == null) {
Log.d(TAG, "Failed to get entity current time, aborting call")
return@launch
}
val domain = "media_player"
val service = "media_seek"
val serviceDataMap: HashMap<String, Any> = hashMapOf(
"entity_id" to entity.entityId,
"seek_position" to currentTime - 10
)
integrationUseCase.callService(domain, service, serviceDataMap)
}
}
private fun callPlayPauseService(context: Context, appWidgetId: Int) {
mainScope.launch {
Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId")
val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (entity == null) {
Log.d(TAG, "Failed to retrieve media player entity")
return@launch
}
Log.d(
TAG, "Calling play/pause service:" + System.lineSeparator() +
"entity id: " + entity.entityId + System.lineSeparator()
)
val domain = "media_player"
val service = "media_play_pause"
val serviceDataMap: HashMap<String, Any> = hashMapOf("entity_id" to entity.entityId)
integrationUseCase.callService(domain, service, serviceDataMap)
}
}
private fun callFastForwardService(context: Context, appWidgetId: Int) {
mainScope.launch {
Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId")
val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (entity == null) {
Log.d(TAG, "Failed to retrieve media player entity")
return@launch
}
Log.d(
TAG, "Calling fast forward service:" + System.lineSeparator() +
"entity id: " + entity.entityId + System.lineSeparator()
)
val currentEntityInfo: Entity<Map<String, Any>>
try {
currentEntityInfo = integrationUseCase.getEntity(entity.entityId)
} catch (e: Exception) {
Log.d(TAG, "Failed to fetch entity or entity does not exist")
Toast.makeText(context, R.string.widget_entity_fetch_error, Toast.LENGTH_LONG).show()
return@launch
}
val fetchedAttributes = currentEntityInfo.attributes
val currentTime = fetchedAttributes["media_position"]?.toString()?.toDoubleOrNull()
if (currentTime == null) {
Log.d(TAG, "Failed to get entity current time, aborting call")
return@launch
}
val domain = "media_player"
val service = "media_seek"
val serviceDataMap: HashMap<String, Any> = hashMapOf(
"entity_id" to entity.entityId,
"seek_position" to currentTime + 10
)
integrationUseCase.callService(domain, service, serviceDataMap)
}
}
private fun callNextTrackService(context: Context, appWidgetId: Int) {
mainScope.launch {
Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId")
val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId)
if (entity == null) {
Log.d(TAG, "Failed to retrieve media player entity")
return@launch
}
Log.d(
TAG, "Calling next track service:" + System.lineSeparator() +
"entity id: " + entity.entityId + System.lineSeparator()
)
val domain = "media_player"
val service = "media_next_track"
val serviceDataMap: HashMap<String, Any> = hashMapOf("entity_id" to entity.entityId)
integrationUseCase.callService(domain, service, serviceDataMap)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
mediaPlayCtrlWidgetDao = AppDatabase.getInstance(context).mediaPlayCtrlWidgetDao()
// When the user deletes the widget, delete the preference associated with it.
for (appWidgetId in appWidgetIds) {
mediaPlayCtrlWidgetDao.delete(appWidgetId)
}
}
override fun onEnabled(context: Context) {
// Enter relevant functionality for when the first widget is created
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
private fun ensureInjected(context: Context) {
if (context.applicationContext is GraphComponentAccessor) {
DaggerProviderComponent.builder()
.appComponent((context.applicationContext as GraphComponentAccessor).appComponent)
.build()
.inject(this)
} else {
throw Exception("Application Context passed is not of our application!")
}
}
}

View file

@ -0,0 +1,170 @@
package io.homeassistant.companion.android.widgets.media_player_controls
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.AutoCompleteTextView
import android.widget.Toast
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.widgets.DaggerProviderComponent
import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter
import javax.inject.Inject
import kotlinx.android.synthetic.main.widget_media_controls_configure.add_button
import kotlinx.android.synthetic.main.widget_media_controls_configure.label
import kotlinx.android.synthetic.main.widget_media_controls_configure.widget_show_seek_buttons_checkbox
import kotlinx.android.synthetic.main.widget_media_controls_configure.widget_show_skip_buttons_checkbox
import kotlinx.android.synthetic.main.widget_media_controls_configure.widget_text_config_entity_id
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class MediaPlayerControlsWidgetConfigureActivity : Activity() {
companion object {
private const val TAG: String = "MediaWidgetConfigAct"
}
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
@Inject
lateinit var integrationUseCase: IntegrationRepository
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
private var entities = LinkedHashMap<String, Entity<Any>>()
private var selectedEntity: Entity<Any>? = null
public override fun onCreate(icicle: Bundle?) {
super.onCreate(icicle)
// Set the result to CANCELED. This will cause the widget host to cancel
// out of the widget placement if the user presses the back button.
setResult(RESULT_CANCELED)
setContentView(R.layout.widget_media_controls_configure)
add_button.setOnClickListener(addWidgetButtonClickListener)
// Find the widget id from the intent.
val intent = intent
val extras = intent.extras
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID
)
}
// If this activity was started with an intent without an app widget ID, finish with an error.
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// Inject components
DaggerProviderComponent.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.build()
.inject(this)
val entityAdapter = SingleItemArrayAdapter<Entity<Any>>(this) { it?.entityId ?: "" }
widget_text_config_entity_id.setAdapter(entityAdapter)
widget_text_config_entity_id.onFocusChangeListener = dropDownOnFocus
widget_text_config_entity_id.onItemClickListener = entityDropDownOnItemClick
mainScope.launch {
try {
// Fetch entities
val fetchedEntities = integrationUseCase.getEntities()
fetchedEntities.sortBy { e -> e.entityId }
fetchedEntities.forEach {
val entityId = it.entityId
val domain = entityId.split(".")[0]
if (domain == "media_player") {
entities[entityId] = it
}
}
entityAdapter.addAll(entities.values)
entityAdapter.sort()
runOnUiThread {
entityAdapter.notifyDataSetChanged()
}
} catch (e: Exception) {
// If entities fail to load, it's okay to pass
// an empty map to the dynamicFieldAdapter
Log.e(TAG, "Failed to query entities", e)
}
}
}
private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus ->
if (hasFocus && view is AutoCompleteTextView) {
view.showDropDown()
}
}
private val entityDropDownOnItemClick =
AdapterView.OnItemClickListener { parent, _, position, _ ->
selectedEntity = parent.getItemAtPosition(position) as Entity<Any>?
}
private var addWidgetButtonClickListener = View.OnClickListener {
try {
val context = this@MediaPlayerControlsWidgetConfigureActivity
// Set up a broadcast intent and pass the service call data as extras
val intent = Intent()
intent.action = MediaPlayerControlsWidget.RECEIVE_DATA
intent.component = ComponentName(context, MediaPlayerControlsWidget::class.java)
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
intent.putExtra(
MediaPlayerControlsWidget.EXTRA_ENTITY_ID,
selectedEntity!!.entityId
)
intent.putExtra(
MediaPlayerControlsWidget.EXTRA_LABEL,
label.text.toString()
)
intent.putExtra(
MediaPlayerControlsWidget.EXTRA_SHOW_SKIP,
widget_show_skip_buttons_checkbox.isChecked
)
intent.putExtra(
MediaPlayerControlsWidget.EXTRA_SHOW_SEEK,
widget_show_seek_buttons_checkbox.isChecked
)
context.sendBroadcast(intent)
// Make sure we pass back the original appWidgetId
setResult(
RESULT_OK,
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
)
finish()
} catch (e: Exception) {
Log.e(TAG, "Issue configuring widget", e)
Toast.makeText(applicationContext, R.string.widget_creation_error, Toast.LENGTH_LONG)
.show()
}
}
override fun onDestroy() {
mainScope.cancel()
super.onDestroy()
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M13,6V18L21.5,12M4,18L12.5,12L4,6V18Z" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M16,18H18V6H16M6,18L14.5,12L6,6V18Z" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M3,5V19L11,12M13,19H16V5H13M18,5V19H21V5" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M6,18V6H8V18H6M9.5,12L18,6V18L9.5,12Z" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M11.5,12L20,18V6M11,18V6L2.5,12L11,18Z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widgetLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="40dp"
android:minWidth="40dp"
android:background="@drawable/widget_button_background">
<LinearLayout
android:id="@+id/widgetImageButtonLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<FrameLayout
android:layout_width="60dp"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widgetMediaImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible"
android:contentDescription="@string/widget_media_image_description"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/widgetMediaPlaceholder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:src="@drawable/app_icon"
android:contentDescription="@string/widget_media_image_description"
android:scaleType="fitCenter" />
</FrameLayout>
<RelativeLayout
android:id="@+id/widgetLabelLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:minHeight="32sp"
android:layout_margin="4dp"
android:orientation="vertical">
<TextView
android:id="@+id/widgetLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_label_placeholder_text"
android:textSize="24sp"
android:textAlignment="center"
android:gravity="center"
android:textColor="@color/colorWidgetButtonLabel"
android:minLines="1"
android:maxLines="2"
/>
<LinearLayout
android:id="@+id/widgetMediaButtonlayout"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_below="@id/widgetLabel"
android:layout_alignParentBottom="true"
android:padding="4dp"
android:orientation="horizontal">
<ImageButton
android:id="@+id/widgetPrevTrackButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:padding="4dp"
android:contentDescription="@string/widget_media_prev_track_button"
android:src="@drawable/ic_prev_track"
android:tint="@color/colorIcon"
android:scaleType="fitCenter"
android:background="?android:selectableItemBackground"/>
<ImageButton
android:id="@+id/widgetRewindButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:padding="4dp"
android:contentDescription="@string/widget_media_rewind_button"
android:src="@drawable/ic_rewind"
android:tint="@color/colorIcon"
android:scaleType="fitCenter"
android:background="?android:selectableItemBackground"/>
<ImageButton
android:id="@+id/widgetPlayPauseButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1.5"
android:padding="4dp"
android:contentDescription="@string/widget_media_playpause_button"
android:src="@drawable/ic_playpause"
android:tint="@color/colorIcon"
android:scaleType="fitCenter"
android:background="?android:selectableItemBackground"/>
<ImageButton
android:id="@+id/widgetFastForwardButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:padding="4dp"
android:contentDescription="@string/widget_media_fastforward_button"
android:src="@drawable/ic_fastforward"
android:tint="@color/colorIcon"
android:scaleType="fitCenter"
android:background="?android:selectableItemBackground"/>
<ImageButton
android:id="@+id/widgetNextTrackButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:padding="4dp"
android:contentDescription="@string/widget_media_next_track_button"
android:src="@drawable/ic_next_track"
android:tint="@color/colorIcon"
android:scaleType="fitCenter"
android:background="?android:selectableItemBackground"/>
</LinearLayout>
</RelativeLayout>
</LinearLayout>
</FrameLayout>

View file

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
style="@style/TextAppearance.HomeAssistant.Headline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/select_entity_to_display" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="20dp"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:labelFor="@id/widget_text_config_entity_id"
android:padding="5dp"
android:text="@string/label_entity_id" />
<AutoCompleteTextView
android:id="@+id/widget_text_config_entity_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="0"
android:imeOptions="actionNext"
android:inputType="text" />
</LinearLayout>
<CheckBox
android:id="@+id/widget_show_skip_buttons_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layoutDirection="rtl"
android:layout_margin="5dp"
android:checked="true"
android:text="@string/widget_media_show_skip" />
<CheckBox
android:id="@+id/widget_show_seek_buttons_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layoutDirection="rtl"
android:layout_margin="5dp"
android:checked="true"
android:text="@string/widget_media_show_seek" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:padding="5dp"
android:text="@string/label_label" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/widget_text_hint_label"
android:imeOptions="actionDone"
android:inputType="text" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/add_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="8dp"
android:text="@string/add_widget" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -330,6 +330,7 @@ like to connect to:</string>
<string name="widget_button_image_description">Service Button</string>
<string name="widget_config_service_error">A custom component is preventing service data from loading.</string>
<string name="widget_creation_error">Unable to create widget.</string>
<string name="widget_entity_fetch_error">Unable to fetch data for configured entity.</string>
<string name="widget_image_description">Home Assistant Widget</string>
<string name="widget_label_placeholder_text">Label</string>
<string name="widget_separator_input_hint">No separator</string>
@ -343,5 +344,13 @@ like to connect to:</string>
<string name="widget_text_hint_service_service">Service</string>
<string name="widget_text_size_default">30</string>
<string name="widget_text_size_label">Widget text size:</string>
<string name="nfc_invalid_tag">This tag does not contain Home Assistant data</string>
<string name="widget_media_show_skip">Show Skip Buttons</string>
<string name="widget_media_show_seek">Show Seek Buttons</string>
<string name="widget_media_image_description">Media Playing Preview Image</string>
<string name="widget_media_prev_track_button">Previous Track</string>
<string name="widget_media_rewind_button">Rewind</string>
<string name="widget_media_playpause_button">Play/Pause</string>
<string name="widget_media_fastforward_button">Fast Forward</string>
<string name="widget_media_next_track_button">Next Track</string>
<string name="nfc_invalid_tag">This tag does not contain Home Assistant data</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="io.homeassistant.companion.android.widgets.media_player_controls.MediaPlayerControlsWidgetConfigureActivity"
android:initialKeyguardLayout="@layout/widget_media_controls"
android:initialLayout="@layout/widget_media_controls"
android:minWidth="250dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"
android:previewImage="@drawable/widget_example_media_player_controls" />

View file

@ -81,6 +81,9 @@ object Config {
const val okhttp = "com.squareup.okhttp3:okhttp:$okhttpVersion"
const val okhttpInterceptor = "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
const val okhttpMockServer = "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
private const val picassoVersion = "2.8"
const val picasso = "com.squareup.picasso:picasso:${picassoVersion}"
}
object Testing {