Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c99dc1b
Refactor MediaPlayerControlsWidgetConfigureActivity to Compose and Ma…
Shaan-alpha Jun 14, 2026
423f518
style: apply ktlint formatting to MediaPlayerControlsWidgetConfigureA…
Shaan-alpha Jun 14, 2026
b0a8847
fix: address review feedback on media widget config screen
Shaan-alpha Jun 14, 2026
920c3fc
refactor: make server-select contract optional for Compose widget scr…
Shaan-alpha Jun 16, 2026
8bfb566
refactor(widgets): extract previewable media player config screen + s…
Shaan-alpha Jun 16, 2026
01c740f
refactor(widgets): migrate media player widget config to MVVM + desig…
Shaan-alpha Jun 18, 2026
5ba2f00
refactor(widgets): address review feedback on media player widget config
Shaan-alpha Jun 19, 2026
58d545a
refactor(widgets): restore multi-entity selection and address review …
Shaan-alpha Jun 19, 2026
38ab412
fix(widgets): resolve media player widget snackbar text via stringRes…
Shaan-alpha Jun 20, 2026
ad413d2
feat(widgets): default new media player widget background to dynamic …
Shaan-alpha Jun 20, 2026
94ec420
refactor(widgets): de-duplicate restored entities and log widget errors
Shaan-alpha Jun 20, 2026
c4e391b
fix(widget): show server placeholder when persisted server is missing
Shaan-alpha Jun 23, 2026
37cdda3
fix(widget): coerce restored DYNAMICCOLOR background when unsupported
Shaan-alpha Jun 23, 2026
dbfe171
refactor(widgets): address media player widget config review feedback
Shaan-alpha Jun 23, 2026
99b7dcc
test: qualify stringResource with compose rule receiver
Shaan-alpha Jun 23, 2026
6a9fea8
Update media player widget screenshots
github-actions[bot] Jun 23, 2026
ae5afa1
Merge branch 'main' into pull/7018
TimoPtr Jul 29, 2026
c886ff6
Align implementation with EntityWidgetConfigure
TimoPtr Jul 29, 2026
445aae6
Apply suggestions
TimoPtr Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,24 @@ package io.homeassistant.companion.android.widgets.mediaplayer
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.MultiAutoCompleteTextView
import android.widget.Spinner
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN
import io.homeassistant.companion.android.common.util.SdkVersion
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.databinding.WidgetMediaControlsConfigureBinding
import dagger.hilt.android.lifecycle.withCreationCallback
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel
import io.homeassistant.companion.android.util.applySafeDrawingInsets
import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter
import io.homeassistant.companion.android.widgets.common.WidgetUtils
import java.util.LinkedList
import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureViewModel.Factory
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber

// TODO Migrate to compose https://github.com/home-assistant/android/issues/6308
@AndroidEntryPoint
class MediaPlayerControlsWidgetConfigureActivity :
BaseWidgetConfigureActivity<MediaPlayerControlsWidgetEntity, MediaPlayerControlsWidgetDao>() {
class MediaPlayerControlsWidgetConfigureActivity : BaseActivity() {

companion object {
private const val FOR_ENTITY = "for_entity"

fun newInstance(context: Context, entityId: String): Intent {
return Intent(context, MediaPlayerControlsWidgetConfigureActivity::class.java).apply {
putExtra(FOR_ENTITY, entityId)
Expand All @@ -46,198 +30,71 @@ class MediaPlayerControlsWidgetConfigureActivity :
}
}

private var requestLauncherSetup = false

private lateinit var binding: WidgetMediaControlsConfigureBinding

override val serverSelect: View
get() = binding.serverSelect
private val widgetId: Int
get() = intent.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID

override val serverSelectList: Spinner
get() = binding.serverSelectList

private var entities = mutableMapOf<Int, List<Entity>>()
private var selectedEntities: LinkedList<Entity?> = LinkedList()
private val viewModel: MediaPlayerControlsWidgetConfigureViewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<Factory> { factory ->
factory.create(widgetId, intent.extras?.getString(FOR_ENTITY, null))
}
},
)
Comment thread
Shaan-alpha marked this conversation as resolved.

private var entityAdapter: SingleItemArrayAdapter<Entity>? = null
private val requestLauncherSetup: Boolean
get() = intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) == true

public override fun onCreate(savedInstanceState: Bundle?) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 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.
// Set the result to CANCELED. This will cause the widget host to cancel out of the widget
// placement if the user closes the screen or presses the back button.
setResult(RESULT_CANCELED)

binding = WidgetMediaControlsConfigureBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.root.applySafeDrawingInsets()
val widgetId = intent.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID

binding.addButton.setOnClickListener {
lifecycleScope.launch {
if (requestLauncherSetup) {
if (
SdkVersion.isAtLeast(Build.VERSION_CODES.O) &&
isValidServerId() &&
binding.widgetTextConfigEntityId.text.split(",").any {
entities[selectedServerId!!].orEmpty().any { e -> e.entityId == it.trim() }
}
) {
requestWidgetCreation()
} else {
showAddWidgetError()
}
} else {
updateWidget()
}
}
}

// Find the widget id from the intent.
val intent = intent
val extras = intent.extras
if (extras != null) {
if (extras.containsKey(FOR_ENTITY)) {
binding.widgetTextConfigEntityId.setText(extras.getString(FOR_ENTITY))
}
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
)
requestLauncherSetup = extras.getBoolean(
ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER,
false,
)
}

// If this activity was started with an intent without an app widget ID, finish with an error.
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) {
finish()
return
}

val backgroundTypeValues = WidgetUtils.getBackgroundOptionList(this)
binding.backgroundType.adapter =
ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, backgroundTypeValues)

lifecycleScope.launch {
val mediaPlayerWidget = dao.get(appWidgetId)

if (mediaPlayerWidget != null) {
binding.label.setText(mediaPlayerWidget.label)
binding.widgetTextConfigEntityId.setText(mediaPlayerWidget.entityId)
binding.widgetShowVolumeButtonCheckbox.isChecked = mediaPlayerWidget.showVolume
binding.widgetShowSeekButtonsCheckbox.isChecked = mediaPlayerWidget.showSeek
binding.widgetShowSkipButtonsCheckbox.isChecked = mediaPlayerWidget.showSkip
binding.widgetShowMediaPlayerSource.isChecked = mediaPlayerWidget.showSource
binding.backgroundType.setSelection(
WidgetUtils.getSelectedBackgroundOption(
this@MediaPlayerControlsWidgetConfigureActivity,
mediaPlayerWidget.backgroundType,
backgroundTypeValues,
),
setContent {
HATheme {
MediaPlayerControlsWidgetConfigureScreen(
viewModel = viewModel,
// The app sets the extra when it opens this screen itself, so there is
// something to go back to. The launcher opens it through the
// APPWIDGET_CONFIGURE filter instead, leaving nothing behind us.
canNavigateBack = requestLauncherSetup,
onNavigate = ::finish,
onActionClick = ::onActionClick,
)
val entities = runBlocking {
try {
mediaPlayerWidget.entityId.split(",").map { s ->
serverManager.integrationRepository(mediaPlayerWidget.serverId).getEntity(s.trim())
}
} catch (e: Exception) {
Timber.e(e, "Unable to get entity information")
Toast.makeText(
applicationContext,
commonR.string.widget_entity_fetch_error,
Toast.LENGTH_LONG,
)
.show()
null
}
}
if (entities != null) {
selectedEntities.addAll(entities)
}
binding.addButton.setText(commonR.string.update_widget)
}
setupServerSelect(mediaPlayerWidget?.serverId)
}
}

entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" }

binding.widgetTextConfigEntityId.setAdapter(entityAdapter)
binding.widgetTextConfigEntityId.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())
binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus

private fun onActionClick() {
lifecycleScope.launch {
serverManager.servers().forEach { server ->
launch {
try {
val fetchedEntities = serverManager.integrationRepository(server.id).getEntities().orEmpty()
.filter { it.domain == MEDIA_PLAYER_DOMAIN }
entities[server.id] = fetchedEntities
if (server.id == selectedServerId) setAdapterEntities(server.id)
} catch (e: Exception) {
// If entities fail to load, it's okay to pass
// an empty map to the dynamicFieldAdapter
Timber.e(e, "Failed to query entities")
}
if (requestLauncherSetup) {
if (viewModel.requestWidgetCreation(this@MediaPlayerControlsWidgetConfigureActivity)) {
finish()
}
} else {
if (viewModel.updateWidgetConfiguration()) {
viewModel.updateWidget(this@MediaPlayerControlsWidgetConfigureActivity)
setResult(
RESULT_OK,
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId),
)
finish()
}
}
}
}

private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus ->
if (hasFocus && view is AutoCompleteTextView) {
view.showDropDown()
}
}

override fun onServerSelected(serverId: Int) {
selectedEntities.clear()
binding.widgetTextConfigEntityId.setText("")
setAdapterEntities(serverId)
}

private fun setAdapterEntities(serverId: Int) {
entityAdapter?.let { adapter ->
adapter.clearAll()
if (entities[serverId] != null) {
adapter.addAll(entities[serverId].orEmpty().toMutableList())
adapter.sort()
}
runOnUiThread { adapter.notifyDataSetChanged() }
}
}

override suspend fun getPendingDaoEntity(): MediaPlayerControlsWidgetEntity {
val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" }
selectedEntities = LinkedList()
val se = binding.widgetTextConfigEntityId.text.split(",")
se.forEach {
val entity = entities[serverId]?.firstOrNull { e -> e.entityId == it.trim() }
if (entity != null) selectedEntities.add(entity)
}

val entitySelection = selectedEntities.map { e -> e?.entityId }.reduceOrNull { a, b -> "$a,$b" }

if (entitySelection == null) {
throw IllegalStateException("No valid entities selected")
}

return MediaPlayerControlsWidgetEntity(
id = appWidgetId,
serverId = serverId,
entityId = entitySelection,
label = binding.label.text.toString(),
showVolume = binding.widgetShowVolumeButtonCheckbox.isChecked,
showSkip = binding.widgetShowSkipButtonsCheckbox.isChecked,
showSeek = binding.widgetShowSeekButtonsCheckbox.isChecked,
showSource = binding.widgetShowMediaPlayerSource.isChecked,
backgroundType = when (binding.backgroundType.selectedItem as String?) {
getString(commonR.string.widget_background_type_dynamiccolor) -> WidgetBackgroundType.DYNAMICCOLOR
getString(commonR.string.widget_background_type_transparent) -> WidgetBackgroundType.TRANSPARENT
else -> WidgetBackgroundType.DAYNIGHT
},
)
}

override val widgetClass: Class<*> = MediaPlayerControlsWidget::class.java
}
Loading
Loading