From 538cb4177a9d8ff87087c2dafccede33daa2ce57 Mon Sep 17 00:00:00 2001 From: chiangww <119795251+chiangww@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:56:37 +0800 Subject: [PATCH] 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 --- .../subtracks/data/prefs/UserPreferences.kt | 8 ++ .../subtracks/ui/playback/NowPlayingScreen.kt | 13 +++- .../subtracks/ui/settings/SettingsScreen.kt | 25 +++++++ .../ui/settings/SettingsViewModel.kt | 9 +++ app/src/main/res/values/strings.xml | 2 + .../data/prefs/UserPreferencesTest.kt | 18 +++++ .../ui/settings/SettingsScreenTest.kt | 73 +++++++++++++++++++ 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index e4b829fa..cb35f8cf 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -174,6 +174,14 @@ class UserPreferences( private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db") + fun keepScreenOn(): Flow = 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 = store.data.map { prefs -> prefs[offlineModeKey] ?: false } suspend fun setOfflineMode(enabled: Boolean) { diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 290bcf41..785520f7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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 }, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 131f5be2..04607211 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index f59011d7..16caac8f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -76,6 +76,11 @@ class SettingsViewModel( .replayGainPreampDb() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f) + val keepScreenOn: StateFlow = + userPreferences + .keepScreenOn() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val offline: StateFlow = sourceRepository.offline val verboseLogging: StateFlow = @@ -134,4 +139,8 @@ class SettingsViewModel( fun setReplayGainPreamp(value: Float) { viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) } } + + fun setKeepScreenOn(enabled: Boolean) { + viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 469a461e..b4c6e1a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,8 @@ %1$s dB Normalise loudness using the ReplayGain tags from the server ReplayGain + Keep the screen awake while now playing is open + Keep screen on Network Stream quality: Mobile Stream quality: Wi-Fi diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index 58aa5252..ae540e09 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt new file mode 100644 index 00000000..094ac9e5 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt @@ -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() + + @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() + } + } +}