Skip to content

Commit 7293470

Browse files
TimoPtrjpelgrom
andauthored
Support for auto play video in FrontendScreen (#6806)
* Support for auto play video in FrontendScreen * Adjust logic to address Copilot concern * Update app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com> * Make observeChanges support multiples keys * Move entirely to LocalStorage --------- Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com>
1 parent ae8c3f8 commit 7293470

9 files changed

Lines changed: 158 additions & 70 deletions

File tree

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ internal fun FrontendScreen(
120120
val pendingPermissionRequest by viewModel.pendingPermissionRequest.collectAsStateWithLifecycle()
121121
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
122122
val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle()
123+
val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle()
123124

124125
// The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen
125126
// state so it does not leak across configuration changes via the ViewModel.
@@ -168,6 +169,7 @@ internal fun FrontendScreen(
168169
webViewActions = viewModel.webViewActions,
169170
onGesture = viewModel::onGesture,
170171
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
172+
autoPlayVideoEnabled = autoPlayVideoEnabled,
171173
modifier = modifier,
172174
)
173175
}
@@ -191,6 +193,7 @@ internal fun FrontendScreenContent(
191193
onWebViewCreationFailed: (Throwable) -> Unit,
192194
modifier: Modifier = Modifier,
193195
customView: View? = null,
196+
autoPlayVideoEnabled: Boolean = false,
194197
pendingPermissionRequest: PermissionRequest? = null,
195198
pendingDialog: FrontendDialog? = null,
196199
pendingFileChooser: FileChooserRequest? = null,
@@ -209,6 +212,7 @@ internal fun FrontendScreenContent(
209212
url = viewState.url,
210213
frontendJsCallback = frontendJsCallback,
211214
webViewActions = webViewActions,
215+
autoPlayVideoEnabled = autoPlayVideoEnabled,
212216
)
213217

214218
PendingPermissionHandler(
@@ -234,6 +238,7 @@ internal fun FrontendScreenContent(
234238
onWebViewCreationFailed = onWebViewCreationFailed,
235239
onDownloadRequested = onDownloadRequested,
236240
onGesture = onGesture,
241+
autoPlayVideoEnabled = autoPlayVideoEnabled,
237242
)
238243

239244
ExoPlayerOverlay(
@@ -421,6 +426,7 @@ private fun SafeHAWebView(
421426
webViewClient: WebViewClient,
422427
contentState: FrontendViewState.Content?,
423428
onWebViewCreationFailed: (Throwable) -> Unit,
429+
autoPlayVideoEnabled: Boolean,
424430
webChromeClient: WebChromeClient? = null,
425431
onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit = { _, _, _ -> },
426432
onGesture: (GestureDirection, Int) -> Unit = { _, _ -> },
@@ -465,6 +471,7 @@ private fun SafeHAWebView(
465471
onWebViewCreated = onWebViewCreated,
466472
onDownloadRequested = onDownloadRequested,
467473
onGesture = onGesture,
474+
autoPlayVideoEnabled = autoPlayVideoEnabled,
468475
)
469476
},
470477
onBackPressed = onBackClick,
@@ -512,13 +519,16 @@ private fun WebView.configureForFrontend(
512519
onWebViewCreated: (WebView) -> Unit,
513520
onDownloadRequested: (url: String, contentDisposition: String, mimetype: String) -> Unit,
514521
onGesture: (GestureDirection, Int) -> Unit,
522+
autoPlayVideoEnabled: Boolean,
515523
) {
516524
onWebViewCreated(this)
517525

518526
this.webViewClient = webViewClient
519527

520528
webChromeClient?.let { this.webChromeClient = it }
521529

530+
settings.mediaPlaybackRequiresUserGesture = !autoPlayVideoEnabled
531+
522532
// Enable first-party cookies globally and third-party cookies for this WebView.
523533
// The Home Assistant frontend relies on third-party cookies for some integrations
524534
// (e.g. embedded content served from a different origin).
@@ -589,14 +599,16 @@ private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) {
589599
}
590600

591601
/**
592-
* Handles WebView side effects: URL loading and [WebViewAction] dispatch.
602+
* Handles WebView side effects: URL loading, [WebViewAction] dispatch, and reapplying the
603+
* "Autoplay video" preference (which requires a [WebView.reload] to take effect on the loaded page).
593604
*/
594605
@Composable
595606
private fun WebViewEffects(
596607
webView: WebView?,
597608
url: String,
598609
frontendJsCallback: FrontendJsCallback,
599610
webViewActions: Flow<WebViewAction>,
611+
autoPlayVideoEnabled: Boolean,
600612
) {
601613
if (webView != null) {
602614
LaunchedEffect(webView, url) {
@@ -614,6 +626,12 @@ private fun WebViewEffects(
614626
action.run(webView)
615627
}
616628
}
629+
LaunchedEffect(autoPlayVideoEnabled, webView) {
630+
val target = !autoPlayVideoEnabled
631+
if (webView.settings.mediaPlaybackRequiresUserGesture == target) return@LaunchedEffect
632+
webView.settings.mediaPlaybackRequiresUserGesture = target
633+
webView.reload()
634+
}
617635
}
618636
}
619637

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import kotlinx.coroutines.flow.asSharedFlow
5454
import kotlinx.coroutines.flow.asStateFlow
5555
import kotlinx.coroutines.flow.collectLatest
5656
import kotlinx.coroutines.flow.distinctUntilChanged
57+
import kotlinx.coroutines.flow.emitAll
58+
import kotlinx.coroutines.flow.flow
5759
import kotlinx.coroutines.flow.map
5860
import kotlinx.coroutines.flow.merge
5961
import kotlinx.coroutines.flow.stateIn
@@ -233,6 +235,19 @@ internal class FrontendViewModel @VisibleForTesting constructor(
233235
/** Job tracking the zoom settings flow collection - restarted on each page load. */
234236
private var zoomObserverJob: Job? = null
235237

238+
/**
239+
* The user's "Autoplay video" preference.
240+
*
241+
* Lives outside [FrontendViewState] because the WebView is rendered during `Loading`,
242+
* `Content`, and `Error`states , and all three states need the value. Exposed as a [StateFlow]
243+
* so the screen can read the current value synchronously when configuring the WebView at
244+
* creation time (avoiding a one-shot reload once the persisted value lands) and react to
245+
* subsequent changes via collection.
246+
*/
247+
val autoPlayVideoEnabled: StateFlow<Boolean> = flow {
248+
emitAll(prefsRepository.autoPlayVideoFlow())
249+
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)
250+
236251
init {
237252
viewModelScope.launch {
238253
_viewState.collectLatest { state ->

app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ import org.junit.jupiter.api.Nested
7474
import org.junit.jupiter.api.Test
7575
import org.junit.jupiter.api.extension.ExtendWith
7676
import org.junit.jupiter.api.extension.RegisterExtension
77+
import org.junit.jupiter.params.ParameterizedTest
78+
import org.junit.jupiter.params.provider.ValueSource
7779

7880
@OptIn(ExperimentalCoroutinesApi::class)
7981
@ExtendWith(ConsoleLogExtension::class)
@@ -91,8 +93,10 @@ class FrontendViewModelTest {
9193
private val downloadManager: FrontendDownloadManager = mockk(relaxed = true)
9294
private val gestureHandler: FrontendGestureHandler = mockk(relaxed = true)
9395
private val zoomSettingsFlow = MutableStateFlow(ZoomSettings())
96+
private val autoPlayVideoFlow = MutableStateFlow(false)
9497
private val prefsRepository: PrefsRepository = mockk(relaxed = true) {
9598
coEvery { this@mockk.zoomSettingsFlow() } returns this@FrontendViewModelTest.zoomSettingsFlow
99+
coEvery { this@mockk.autoPlayVideoFlow() } returns this@FrontendViewModelTest.autoPlayVideoFlow
96100
}
97101

98102
private val serverId = 1
@@ -1705,4 +1709,34 @@ class FrontendViewModelTest {
17051709
}
17061710
}
17071711
}
1712+
1713+
@Nested
1714+
inner class AutoPlayVideoSetting {
1715+
1716+
@Test
1717+
fun `Given pref flow emits new value when collected then exposed StateFlow reflects it`() = runTest {
1718+
val viewModel = createViewModel()
1719+
advanceUntilIdle()
1720+
1721+
assertEquals(false, viewModel.autoPlayVideoEnabled.value)
1722+
1723+
autoPlayVideoFlow.value = true
1724+
advanceUntilIdle()
1725+
1726+
assertEquals(true, viewModel.autoPlayVideoEnabled.value)
1727+
}
1728+
1729+
@ParameterizedTest
1730+
@ValueSource(booleans = [true, false])
1731+
fun `Given pref flow seeded with value when ViewModel constructed then exposed StateFlow has that value`(
1732+
value: Boolean,
1733+
) = runTest {
1734+
autoPlayVideoFlow.value = value
1735+
1736+
val viewModel = createViewModel()
1737+
advanceUntilIdle()
1738+
1739+
assertEquals(value, viewModel.autoPlayVideoEnabled.value)
1740+
}
1741+
}
17081742
}

common/src/main/kotlin/io/homeassistant/companion/android/common/LocalStorageImpl.kt

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.channels.awaitClose
99
import kotlinx.coroutines.flow.Flow
1010
import kotlinx.coroutines.flow.callbackFlow
11+
import kotlinx.coroutines.flow.flowOf
12+
import kotlinx.coroutines.flow.map
13+
import kotlinx.coroutines.flow.merge
1114
import kotlinx.coroutines.launch
1215
import kotlinx.coroutines.sync.Mutex
1316
import kotlinx.coroutines.sync.withLock
@@ -110,16 +113,24 @@ class LocalStorageImpl(sharedPreferences: suspend () -> SharedPreferences) : Loc
110113
withContext(Dispatchers.IO) { sharedPreferences().edit { remove(key) } }
111114
}
112115

113-
override fun observeChanges(key: String): Flow<String> = callbackFlow {
116+
override fun observeChanges(vararg keys: String): Flow<String> = callbackFlow {
114117
val prefs = sharedPreferences()
115-
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
116-
if (changedKey == key) {
117-
launch { send(key) }
118+
keys.forEach { key ->
119+
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
120+
if (changedKey == key) {
121+
launch { send(key) }
122+
}
123+
}
124+
prefs.registerOnSharedPreferenceChangeListener(listener)
125+
awaitClose {
126+
prefs.unregisterOnSharedPreferenceChangeListener(listener)
118127
}
119128
}
120-
prefs.registerOnSharedPreferenceChangeListener(listener)
121-
awaitClose {
122-
prefs.unregisterOnSharedPreferenceChangeListener(listener)
123-
}
129+
}
130+
131+
override suspend fun <T> observeChanges(vararg keys: String, mapper: suspend () -> T): Flow<T> {
132+
// Seed an initial emission so collectors read the current value immediately
133+
return merge(observeChanges(*keys), flowOf(""))
134+
.map { mapper() }
124135
}
125136
}

common/src/main/kotlin/io/homeassistant/companion/android/common/data/LocalStorage.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,15 @@ interface LocalStorage {
3232
* Returns a [Flow] that emits the [key] each time the value associated with it changes
3333
* and only emits for the specified [key].
3434
*/
35-
fun observeChanges(key: String): Flow<String>
35+
fun observeChanges(vararg keys: String): Flow<String>
36+
37+
/**
38+
* Returns a [Flow] that emits the result of [mapper] each time the value associated with any
39+
* of the specified [keys] changes. The current mapped value is also emitted immediately upon
40+
* collection so collectors do not need to read the value separately before subscribing.
41+
*
42+
* [mapper] is invoked on every emission, including the initial one, and may suspend to read
43+
* from storage.
44+
*/
45+
suspend fun <T> observeChanges(vararg keys: String, mapper: suspend () -> T): Flow<T>
3646
}

common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ interface PrefsRepository {
9999

100100
suspend fun isAutoPlayVideoEnabled(): Boolean
101101

102+
/** Emits the current "Autoplay video" preference immediately on collection, then on every change. */
103+
suspend fun autoPlayVideoFlow(): Flow<Boolean>
104+
102105
suspend fun setAutoPlayVideo(enabled: Boolean)
103106

104107
suspend fun isAlwaysShowFirstViewOnAppStartEnabled(): Boolean

common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ import io.homeassistant.companion.android.di.qualifiers.NamedThemesStorage
1010
import java.util.concurrent.atomic.AtomicBoolean
1111
import javax.inject.Inject
1212
import kotlinx.coroutines.flow.Flow
13-
import kotlinx.coroutines.flow.flowOf
14-
import kotlinx.coroutines.flow.map
15-
import kotlinx.coroutines.flow.merge
1613
import kotlinx.coroutines.sync.Mutex
1714
import kotlinx.coroutines.sync.withLock
1815

@@ -208,12 +205,7 @@ internal class PrefsRepositoryImpl @Inject constructor(
208205
}
209206

210207
override suspend fun fullScreenEnabledFlow(): Flow<Boolean> {
211-
val localStorage = localStorage()
212-
return merge(
213-
localStorage.observeChanges(PREF_FULLSCREEN_ENABLED),
214-
// Seed an initial emission so collectors read the current value immediately
215-
flowOf(""),
216-
).map {
208+
return localStorage().observeChanges(PREF_FULLSCREEN_ENABLED) {
217209
isFullScreenEnabled()
218210
}
219211
}
@@ -242,25 +234,24 @@ internal class PrefsRepositoryImpl @Inject constructor(
242234
localStorage().putBoolean(PREF_PINCH_TO_ZOOM_ENABLED, enabled)
243235
}
244236

245-
override suspend fun zoomSettingsFlow(): Flow<ZoomSettings> {
246-
val localStorage = localStorage()
247-
return merge(
248-
localStorage.observeChanges(PREF_PAGE_ZOOM_LEVEL),
249-
localStorage.observeChanges(PREF_PINCH_TO_ZOOM_ENABLED),
250-
// Seed an initial emission so collectors read the current values immediately
251-
flowOf(""),
252-
).map {
237+
override suspend fun zoomSettingsFlow(): Flow<ZoomSettings> =
238+
localStorage().observeChanges(PREF_PAGE_ZOOM_LEVEL, PREF_PINCH_TO_ZOOM_ENABLED) {
253239
ZoomSettings(
254240
zoomLevel = getPageZoomLevel(),
255241
pinchToZoomEnabled = isPinchToZoomEnabled(),
256242
)
257243
}
258-
}
259244

260245
override suspend fun isAutoPlayVideoEnabled(): Boolean {
261246
return localStorage().getBoolean(PREF_AUTOPLAY_VIDEO)
262247
}
263248

249+
override suspend fun autoPlayVideoFlow(): Flow<Boolean> {
250+
return localStorage().observeChanges(PREF_AUTOPLAY_VIDEO) {
251+
isAutoPlayVideoEnabled()
252+
}
253+
}
254+
264255
override suspend fun setAutoPlayVideo(enabled: Boolean) {
265256
localStorage().putBoolean(PREF_AUTOPLAY_VIDEO, enabled)
266257
}

common/src/test/kotlin/io/homeassistant/companion/android/common/LocalStorageImplTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,12 @@ class LocalStorageImplTest {
4747
}
4848
verify { sharedPreferences.unregisterOnSharedPreferenceChangeListener(any()) }
4949
}
50+
51+
@Test
52+
fun `Given observing keys with mapper when subscribing then mapper result is emitted immediately`() = runTest {
53+
localStorage.observeChanges("my_key") { "mapped_value" }.test {
54+
assertEquals("mapped_value", awaitItem())
55+
cancelAndIgnoreRemainingEvents()
56+
}
57+
}
5058
}

0 commit comments

Comments
 (0)