3 Commits
Author SHA1 Message Date
austinried fcfc5561ae settings: name the screen in the keep-screen-on description 2026-10-17 01:14:48 +00:00
austinried a562e4b316 settings: exercise the keep-screen-on toggle in tests
The toggle's behaviour was untested: deleting the Modifier.keepScreenOn(),
hard-coding the row's request or emptying the Switch handler all left the
suite green, so none of the three new code paths were actually pinned.

- NowPlayingKeepScreenOnTest renders Now Playing and asserts a view in the
  activity tree holds the screen on while the flag is set and releases it
  once the flag clears.
- SettingsScreenTest now covers off->on and on->off on the row, and the
  Switch itself, instead of only the off->on row click.
- Remember the keep-screen-on flow so NowPlayingRoute stops tearing down
  and rebuilding its DataStore collector on every position tick.
2026-10-17 01:14:48 +00:00
chiangww 538cb4177a 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
2026-10-17 01:14:48 +00:00
8 changed files with 267 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 remember { 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 on 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,85 @@
package com.subtracks.ui.playback
import android.view.View
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.ui.theme.SubtracksTheme
import com.subtracks.ui.theme.artworkColorsFromSeed
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NowPlayingKeepScreenOnTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val state =
PlaybackState(
item =
QueueItem(
id = "s-eiirp",
title = "Everything In Its Right Place",
artist = "Radiohead",
album = "Kid A",
coverArtId = "art-al-kid-a",
),
isPlaying = true,
durationMs = 251_000,
hasNext = true,
hasPrevious = false,
)
@Test
fun theScreenOnFlagTracksTheSetting() {
var keepScreenOn by mutableStateOf(true)
composeRule.setContent {
SubtracksTheme {
NowPlayingScreen(
state = state,
positionMs = 62_000,
title = "Kid A",
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artwork = artworkColorsFromSeed(android.graphics.Color.rgb(120, 80, 200)),
onBack = {},
onQueue = {},
onPlayPause = {},
onNext = {},
onPrevious = {},
onSeek = {},
keepScreenOn = keepScreenOn,
)
}
}
composeRule.waitForIdle()
assertNotNull("now playing should hold the screen on", viewHoldingTheScreenOn())
composeRule.runOnIdle { keepScreenOn = false }
composeRule.waitForIdle()
assertNull("clearing the setting should release the screen-on flag", viewHoldingTheScreenOn())
}
private fun viewHoldingTheScreenOn(): View? = findKeepingScreenOn(composeRule.activity.window.decorView)
private fun findKeepingScreenOn(view: View): View? {
if (view.keepScreenOn) return view
if (view is ViewGroup) {
for (index in 0 until view.childCount) {
findKeepingScreenOn(view.getChildAt(index))?.let { return it }
}
}
return null
}
}
@@ -0,0 +1,108 @@
package com.subtracks.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.hasAnySibling
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
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 theKeepScreenOnRowTurnsTheSettingOn() {
var requested: Boolean? = null
renderSettings(keepScreenOn = false) { requested = it }
composeRule.onNodeWithText("Keep screen on").performClick()
composeRule.waitForIdle()
assertEquals(true, requested)
}
@Test
fun theKeepScreenOnRowTurnsTheSettingOff() {
var requested: Boolean? = null
renderSettings(keepScreenOn = true) { requested = it }
composeRule.onNodeWithText("Keep screen on").performClick()
composeRule.waitForIdle()
assertEquals(false, requested)
}
@Test
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
var requested: Boolean? = null
renderSettings(keepScreenOn = false) { requested = it }
composeRule
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
.performClick()
composeRule.waitForIdle()
assertEquals(true, requested)
}
private fun renderSettings(
keepScreenOn: Boolean,
onKeepScreenOnChange: (Boolean) -> Unit,
) {
composeRule.setContent {
SubtracksTheme {
SettingsScreen(
sources = emptyList(),
activeSourceId = null,
wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(),
syncConcurrency = 1,
downloadQuality = StreamQuality(),
downloadOverMetered = false,
scrobbling = false,
keepScreenOn = keepScreenOn,
onSelectSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onDownloadOverMeteredChange = {},
onScrobblingChange = {},
onKeepScreenOnChange = onKeepScreenOnChange,
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = {},
onShareLogs = {},
onBack = {},
)
}
}
awaitText("Add server")
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
}
private fun awaitText(text: String) {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
}