feat: add a keep-screen-on toggle for now playing

A screen timeout mid-album is a common complaint for a music player: the
phone lies on a table while art or lyrics are on screen and the display
sleeps. Add a Settings toggle under Playback, off by default, that holds
the screen awake while now playing is open. The flag rides on the now
playing overlay through Compose's keepScreenOn modifier, and since the
overlay is only composed while expanded, collapsing it releases the flag
without any window-flag lifecycle handling.

Closes austinried/subtracks#201
This commit is contained in:
chiangww
2026-10-17 01:14:48 +00:00
committed by austinried
parent b2c66bf03e
commit 538cb4177a
7 changed files with 147 additions and 1 deletions
@@ -174,6 +174,14 @@ class UserPreferences(
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
suspend fun setKeepScreenOn(enabled: Boolean) {
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
}
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) {
@@ -60,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
@@ -76,6 +77,7 @@ import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType
@@ -191,8 +193,10 @@ fun NowPlayingRoute(
controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
downloadRepository: DownloadRepository = koinInject(),
userPreferences: UserPreferences = koinInject(),
) {
val state by controller.state.collectAsStateWithLifecycle()
val keepScreenOn by userPreferences.keepScreenOn().collectAsStateWithLifecycle(initialValue = false)
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
val sourceId = state.context?.sourceId
val songId = state.item?.id
@@ -272,6 +276,7 @@ fun NowPlayingRoute(
starred = song?.starred != null,
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
onSeek = controller::seekTo,
keepScreenOn = keepScreenOn,
modifier = modifier,
)
}
@@ -290,6 +295,7 @@ fun NowPlayingScreen(
onNext: () -> Unit,
onPrevious: () -> Unit,
modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
onSkipPrevious: () -> Unit = onPrevious,
onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {},
@@ -366,7 +372,12 @@ fun NowPlayingScreen(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Box(
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
) {
HeroGradient(
colors = artwork,
scrollPx = { 0f },
@@ -123,6 +123,7 @@ fun SettingsRoute(
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
@@ -142,6 +143,7 @@ fun SettingsRoute(
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
keepScreenOn = keepScreenOn,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
@@ -156,6 +158,7 @@ fun SettingsRoute(
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
@@ -195,6 +198,7 @@ fun SettingsScreen(
downloadOverMetered: Boolean,
scrobbling: Boolean,
modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
offline: Boolean = false,
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
@@ -213,6 +217,7 @@ fun SettingsScreen(
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
@@ -380,6 +385,26 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
trailingContent = {
Switch(
checked = keepScreenOn,
onCheckedChange = onKeepScreenOnChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
@@ -76,6 +76,11 @@ class SettingsViewModel(
.replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
val keepScreenOn: StateFlow<Boolean> =
userPreferences
.keepScreenOn()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
@@ -134,4 +139,8 @@ class SettingsViewModel(
fun setReplayGainPreamp(value: Float) {
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
}
fun setKeepScreenOn(enabled: Boolean) {
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
}
}
+2
View File
@@ -85,6 +85,8 @@
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
<string name="settings_replay_gain_title">ReplayGain</string>
<string name="settings_music_options_keep_screen_on_description">Keep the screen awake while now playing is open</string>
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -155,6 +155,24 @@ class UserPreferencesTest {
file.delete()
}
@Test
fun keepScreenOnDefaultsOffAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(false, prefs.keepScreenOn().first())
prefs.setKeepScreenOn(true)
assertEquals(true, prefs.keepScreenOn().first())
prefs.setKeepScreenOn(false)
assertEquals(false, prefs.keepScreenOn().first())
file.delete()
}
@Test
fun lastSeedRoundTripsWithAndWithoutASecondary() =
runTest {
@@ -0,0 +1,73 @@
package com.subtracks.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class SettingsScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun theKeepScreenOnRowToggles() {
var requested: Boolean? = null
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
sources = emptyList(),
activeSourceId = null,
wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(),
syncConcurrency = 1,
downloadQuality = StreamQuality(),
downloadOverMetered = false,
scrobbling = false,
keepScreenOn = false,
onSelectSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onDownloadOverMeteredChange = {},
onScrobblingChange = {},
onKeepScreenOnChange = { requested = it },
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = {},
onShareLogs = {},
onBack = {},
)
}
}
awaitText("Add server")
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
composeRule.onNodeWithText("Keep screen on").performClick()
composeRule.waitForIdle()
assertEquals(true, requested)
}
private fun awaitText(text: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
}