Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.xitee.sleeptimer.core.data.model

enum class MediaEndAction {
Pause,
Stop,
;

companion object {
val Default: MediaEndAction = Pause

fun fromStorage(value: String?): MediaEndAction =
entries.firstOrNull { it.name == value } ?: Default
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package dev.xitee.sleeptimer.core.data.model

data class UserSettings(
val stopMediaPlayback: Boolean = true,
val mediaEndAction: MediaEndAction = MediaEndAction.Default,
val fadeOutDurationSeconds: Int = 30,
val screenOff: Boolean = false,
val softScreenOff: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package dev.xitee.sleeptimer.core.data.repository

import dev.xitee.sleeptimer.core.data.model.AutoRotateMode
import dev.xitee.sleeptimer.core.data.model.MediaEndAction
import dev.xitee.sleeptimer.core.data.model.ThemeId
import dev.xitee.sleeptimer.core.data.model.UserSettings
import kotlinx.coroutines.flow.Flow

interface SettingsRepository {
val settings: Flow<UserSettings>
suspend fun updateStopMediaPlayback(enabled: Boolean)
suspend fun updateMediaEndAction(action: MediaEndAction)
suspend fun updateFadeOutDuration(seconds: Int)
suspend fun updateScreenOff(enabled: Boolean)
suspend fun updateSoftScreenOff(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.xitee.sleeptimer.core.data.model.AutoRotateMode
import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES
import dev.xitee.sleeptimer.core.data.model.MediaEndAction
import dev.xitee.sleeptimer.core.data.model.ThemeId
import dev.xitee.sleeptimer.core.data.model.UserSettings
import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled
Expand All @@ -34,6 +35,7 @@ class SettingsRepositoryImpl @Inject constructor(

private companion object {
val STOP_MEDIA = booleanPreferencesKey("stop_media_playback")
val MEDIA_END_ACTION = stringPreferencesKey("media_end_action")
val FADE_OUT_DURATION = intPreferencesKey("fade_out_duration_seconds")
val SCREEN_OFF = booleanPreferencesKey("screen_off")
val SOFT_SCREEN_OFF = booleanPreferencesKey("soft_screen_off")
Expand Down Expand Up @@ -87,6 +89,7 @@ class SettingsRepositoryImpl @Inject constructor(
val d = UserSettings()
UserSettings(
stopMediaPlayback = prefs[STOP_MEDIA] ?: d.stopMediaPlayback,
mediaEndAction = MediaEndAction.fromStorage(prefs[MEDIA_END_ACTION]),
fadeOutDurationSeconds = prefs[FADE_OUT_DURATION] ?: d.fadeOutDurationSeconds,
screenOff = prefs[SCREEN_OFF] ?: d.screenOff,
softScreenOff = prefs[SOFT_SCREEN_OFF] ?: d.softScreenOff,
Expand All @@ -108,6 +111,10 @@ class SettingsRepositoryImpl @Inject constructor(
dataStore.edit { it[STOP_MEDIA] = enabled }
}

override suspend fun updateMediaEndAction(action: MediaEndAction) {
dataStore.edit { it[MEDIA_END_ACTION] = action.name }
}

override suspend fun updateFadeOutDuration(seconds: Int) {
dataStore.edit { it[FADE_OUT_DURATION] = seconds }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ class SleepTimerService : Service() {
updateTimerState(TimerPhase.FADING_OUT)
lastNotifiedMinutes = Int.MIN_VALUE
notificationManager.updateNotification(0, stepMinutes, TimerPhase.FADING_OUT)
mediaVolumeController.fadeOutAndPause(settings.fadeOutDurationSeconds)
mediaVolumeController.fadeOutAndEndPlayback(
settings.fadeOutDurationSeconds,
settings.mediaEndAction,
)
}

if (settings.turnOffWifi && shizukuManager.isReady()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.media.AudioManager
import android.view.KeyEvent
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.xitee.sleeptimer.core.data.model.MediaEndAction
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -15,11 +16,11 @@ class MediaVolumeController @Inject constructor(
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var originalVolume: Int = -1

suspend fun fadeOutAndPause(durationSeconds: Int) {
suspend fun fadeOutAndEndPlayback(durationSeconds: Int, endAction: MediaEndAction) {
originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)

if (originalVolume <= 0 || durationSeconds <= 0) {
pauseMedia()
endPlayback(endAction)
return
}

Expand All @@ -31,7 +32,7 @@ class MediaVolumeController @Inject constructor(
delay(intervalMs)
}

pauseMedia()
endPlayback(endAction)
restoreVolume()
}

Expand All @@ -53,11 +54,13 @@ class MediaVolumeController @Inject constructor(
originalVolume = -1
}

fun pauseMedia() {
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(downEvent)
audioManager.dispatchMediaKeyEvent(upEvent)
private fun endPlayback(action: MediaEndAction) {
val keyCode = when (action) {
MediaEndAction.Pause -> KeyEvent.KEYCODE_MEDIA_PAUSE
MediaEndAction.Stop -> KeyEvent.KEYCODE_MEDIA_STOP
}
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
}

fun restoreVolume() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.MusicOff
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.RocketLaunch
import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material.icons.filled.StopCircle
import androidx.compose.material.icons.filled.Vibration
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
Expand All @@ -58,6 +59,7 @@ import dev.xitee.sleeptimer.core.data.model.AutoRotateMode
import dev.xitee.sleeptimer.feature.timer.R
import dev.xitee.sleeptimer.feature.timer.settings.components.AutoRotateModeDialog
import dev.xitee.sleeptimer.feature.timer.settings.components.FadeOutSlider
import dev.xitee.sleeptimer.feature.timer.settings.components.MediaEndActionDialog
import dev.xitee.sleeptimer.feature.timer.settings.components.ScreenLockMethodDialog
import dev.xitee.sleeptimer.feature.timer.settings.components.labelRes
import dev.xitee.sleeptimer.feature.timer.settings.components.SettingsToggleRow
Expand Down Expand Up @@ -120,6 +122,7 @@ private fun SettingsContent(
var pendingShizukuToggle by remember { mutableStateOf<(() -> Unit)?>(null) }
var showMethodDialog by remember { mutableStateOf(false) }
var showAutoRotateDialog by remember { mutableStateOf(false) }
var showMediaEndActionDialog by remember { mutableStateOf(false) }

fun requestWithShizuku(explanation: String, enableAction: () -> Unit) {
if (viewModel.isShizukuReady()) {
Expand Down Expand Up @@ -180,6 +183,14 @@ private fun SettingsContent(
)
}

if (showMediaEndActionDialog) {
MediaEndActionDialog(
selected = uiState.settings.mediaEndAction,
onSelect = { viewModel.updateMediaEndAction(it) },
onDismiss = { showMediaEndActionDialog = false },
)
}

// Auto-complete the pending toggle if Shizuku transitions to Ready while dialog is open.
LaunchedEffect(uiState.shizukuState, pendingShizukuToggle) {
if (pendingShizukuToggle != null && uiState.shizukuState == ShizukuManager.State.Ready) {
Expand Down Expand Up @@ -259,6 +270,12 @@ private fun SettingsContent(
checked = uiState.settings.stopMediaPlayback,
onCheckedChange = { viewModel.updateStopMediaPlayback(it) },
)
SettingsNavigationRow(
icon = Icons.Default.StopCircle,
title = stringResource(R.string.media_end_action_title),
description = stringResource(uiState.settings.mediaEndAction.labelRes),
onClick = { showMediaEndActionDialog = true },
)
FadeOutSlider(
durationSeconds = uiState.settings.fadeOutDurationSeconds,
onDurationChanged = { viewModel.updateFadeOutDuration(it) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.xitee.sleeptimer.core.data.model.AutoRotateMode
import dev.xitee.sleeptimer.core.data.model.MediaEndAction
import dev.xitee.sleeptimer.core.data.model.ThemeId
import dev.xitee.sleeptimer.core.data.repository.SettingsRepository
import dev.xitee.sleeptimer.core.service.screen.ScreenLockHelper
Expand Down Expand Up @@ -60,6 +61,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.updateStopMediaPlayback(enabled) }
}

fun updateMediaEndAction(action: MediaEndAction) {
viewModelScope.launch { settingsRepository.updateMediaEndAction(action) }
}

fun updateFadeOutDuration(seconds: Int) {
viewModelScope.launch { settingsRepository.updateFadeOutDuration(seconds) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package dev.xitee.sleeptimer.feature.timer.settings.components

import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicOff
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.xitee.sleeptimer.core.data.model.MediaEndAction
import dev.xitee.sleeptimer.feature.timer.R
import dev.xitee.sleeptimer.feature.timer.theme.appTheme

/** Title string shown in the settings row's description slot for the selected action. */
@get:StringRes
val MediaEndAction.labelRes: Int
get() = when (this) {
MediaEndAction.Pause -> R.string.media_end_action_pause_title
MediaEndAction.Stop -> R.string.media_end_action_stop_title
}

@Composable
fun MediaEndActionDialog(
selected: MediaEndAction,
onSelect: (MediaEndAction) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.MusicOff, contentDescription = null) },
title = { Text(stringResource(R.string.media_end_action_dialog_title)) },
text = {
Column {
ActionOption(
icon = Icons.Default.Pause,
title = stringResource(R.string.media_end_action_pause_title),
description = stringResource(R.string.media_end_action_pause_description),
isSelected = selected == MediaEndAction.Pause,
onClick = { onSelect(MediaEndAction.Pause); onDismiss() },
)
Spacer(Modifier.height(8.dp))
ActionOption(
icon = Icons.Default.Stop,
title = stringResource(R.string.media_end_action_stop_title),
description = stringResource(R.string.media_end_action_stop_description),
isSelected = selected == MediaEndAction.Stop,
onClick = { onSelect(MediaEndAction.Stop); onDismiss() },
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.shizuku_action_cancel))
}
},
)
}

@Composable
private fun ActionOption(
icon: ImageVector,
title: String,
description: String,
isSelected: Boolean,
onClick: () -> Unit,
) {
val accent = appTheme().accent
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
tint = if (isSelected) accent else LocalContentColor.current,
modifier = Modifier.size(28.dp),
)
Spacer(Modifier.width(16.dp))
Column {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = if (isSelected) accent else LocalContentColor.current,
fontWeight = if (isSelected) FontWeight.SemiBold else null,
)
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}
6 changes: 6 additions & 0 deletions feature/timer/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
<string name="category_sleep_timer">Sleep Timer</string>
<string name="playback_title">Wiedergabe</string>
<string name="playback_description">Medienwiedergabe stoppen</string>
<string name="media_end_action_title">Pausieren oder stoppen</string>
<string name="media_end_action_dialog_title">Aktion nach Ablauf wählen</string>
<string name="media_end_action_pause_title">Pausieren</string>
<string name="media_end_action_pause_description">Wiedergabe kann an derselben Stelle fortgesetzt werden</string>
<string name="media_end_action_stop_title">Stoppen</string>
<string name="media_end_action_stop_description">Wiedergabesitzung vollständig beenden</string>
<string name="fade_out_title">Fade-out-Dauer</string>
<string name="fade_out_seconds">%d Sekunden</string>
<string name="step_minutes_title">+/− Schrittweite</string>
Expand Down
6 changes: 6 additions & 0 deletions feature/timer/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
<string name="category_sleep_timer">Sleep Timer</string>
<string name="playback_title">Playback</string>
<string name="playback_description">Stop audio and video playback</string>
<string name="media_end_action_title">Pause or stop</string>
<string name="media_end_action_dialog_title">Choose end action</string>
<string name="media_end_action_pause_title">Pause</string>
<string name="media_end_action_pause_description">Playback can be resumed where it left off</string>
<string name="media_end_action_stop_title">Stop</string>
<string name="media_end_action_stop_description">End the playback session completely</string>
<string name="fade_out_title">Fade-out duration</string>
<string name="fade_out_seconds">%d seconds</string>
<string name="step_minutes_title">+/− step</string>
Expand Down
Loading