fix(screensaver): match night brightness across hosts, end on leave

The dream now turns its window down to the same near-zero brightness as
the in-app screen saver, not just to the system's dim level, and builds
its ComposeView from the window's context rather than the service's.
The in-app screen saver finishes once it is no longer shown, so waking
the phone shows the lock screen, and rotating it into a stand no longer
recreates it. The outgoing minute of the crossfade is hidden from screen
readers.
This commit is contained in:
makiolaj committed 2026-10-22 08:51:40 +02:00
1 parent 4765b8e088
commit be2c0ada04
6 files changed
+48 -24

No files matched your search

+5 -4
View File
@@ -307,13 +307,14 @@
android:theme="@style/Theme.Clockula" />
<!-- The same screen saver on demand, from the tabs' overflow menu: an
app cannot start the system's dream itself. Not exported, and
no history: leaving it by Home or Recents finishes it, so the app
never comes back to a dim screen saver instead of its tabs. -->
app cannot start the system's dream itself. Not exported; it
finishes itself once no longer shown. Rotating into a dock or a
stand re-lays it out rather than recreating it, which would
flash black and reset its brightness. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden"
android:exported="false"
android:noHistory="true"
android:theme="@style/Theme.Clockula.ScreenSaver" />
<!-- Persists the per-app language on API < 33, where the platform
@@ -10,7 +10,9 @@ import javax.inject.Inject
* Clockula's screen saver, as the system starts it — on a charger or a dock,
* from Settings → Display → Screen saver. Non-interactive: any touch wakes
* the device, as a screen saver should. Night mode turns the display down to
* the system's dim level ([setScreenBright]) on top of the dimmed colours.
* the system's dim level ([setScreenBright]) and the window's own
* brightness to the same near-zero the in-app host uses, on top of the
* dimmed colours.
*
* Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its
* lifecycle, view-model store and saved-state owners on the window's decor
@@ -32,14 +34,20 @@ class ClockulaDreamService : DreamService() {
isFullscreen = true
// Dim until the settings say otherwise (ScreenSaverRoot).
isScreenBright = false
window.applyScreenSaverBrightness(nightMode = true)
val owner = DreamViewOwner().also { owner = it }
owner.create(window.decorView)
setContentView(
ComposeView(this).apply {
// The window's context, not the service's: a Service is not a UI
// context, and the window's knows its display's density and size.
ComposeView(window.context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
setContent {
ScreenSaverRoot(source, onNightModeChanged = { night -> isScreenBright = !night })
ScreenSaverRoot(source, onNightModeChanged = { night ->
isScreenBright = !night
window.applyScreenSaverBrightness(night)
})
}
},
)
@@ -48,13 +48,13 @@ class ScreenSaverActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
applyNightBrightness(true)
window.applyScreenSaverBrightness(nightMode = true)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent { ScreenSaverRoot(source, onNightModeChanged = ::applyNightBrightness) }
setContent { ScreenSaverRoot(source, onNightModeChanged = window::applyScreenSaverBrightness) }
}
override fun onStart() {
@@ -73,6 +73,11 @@ class ScreenSaverActivity : ComponentActivity() {
override fun onStop() {
unregisterReceiver(powerReceiver)
super.onStop()
// Gone once it is no longer shown — Home, Recents, or the screen
// timing out on battery — as a dream ends, so the next wake shows the
// lock screen and the next launch the app, never a screen saver left
// behind. A rotation recreates nothing (configChanges in the manifest).
if (!isChangingConfigurations) finish()
}
override fun onUserInteraction() {
@@ -90,15 +95,4 @@ class ScreenSaverActivity : ComponentActivity() {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun applyNightBrightness(nightMode: Boolean) {
window.attributes = window.attributes.apply {
screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
private companion object {
/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
const val NIGHT_BRIGHTNESS = 0.01f
}
}
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
@@ -80,15 +81,18 @@ internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier =
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
label = "drift",
) { shown ->
Drifting(shown.position) { Clock(shown, side) }
// The outgoing minute is only a picture while it fades: a screen
// reader finds one time on screen, never two.
val semantics = if (shown == state) Modifier else Modifier.clearAndSetSemantics {}
Drifting(shown.position, semantics) { Clock(shown, side) }
}
}
}
/** Places its one child at [position]'s fractions of the space the child leaves free. */
@Composable
private fun Drifting(position: ScreenSaverDrift.Position, content: @Composable () -> Unit) {
Layout(content = content, modifier = Modifier.fillMaxSize()) { measurables, constraints ->
private fun Drifting(position: ScreenSaverDrift.Position, modifier: Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier.fillMaxSize()) { measurables, constraints ->
val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0))
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.view.Window
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +15,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme
import de.jeanlucmakiola.floret.prefs.Appearance
/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
private const val NIGHT_BRIGHTNESS = 0.01f
/**
* Both hosts' display brightness for [nightMode]: almost nothing at night,
* the user's own otherwise. The same window setting for the dream and the
* activity, so the system's screen saver is no brighter at night than the
* one opened from the menu.
*/
internal fun Window.applyScreenSaverBrightness(nightMode: Boolean) {
attributes = attributes.apply {
screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
/**
* What both hosts set as their content: [source]'s state in the screen
* saver's theme. Collected with the host's lifecycle, so a stopped dream or
@@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle