diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/MediaEndAction.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/MediaEndAction.kt new file mode 100644 index 0000000..80ea594 --- /dev/null +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/MediaEndAction.kt @@ -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 + } +} diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt index 6749eff..8a599a3 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt @@ -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, diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt index a6f7492..b818a42 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.Flow interface SettingsRepository { val settings: Flow 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) diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt index ae0ec26..ae9ada0 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt @@ -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 @@ -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") @@ -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, @@ -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 } } diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt index dfeab06..a4ff190 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt @@ -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()) { diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/media/MediaVolumeController.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/media/MediaVolumeController.kt index 90acca7..0807535 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/media/MediaVolumeController.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/media/MediaVolumeController.kt @@ -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 @@ -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 } @@ -31,7 +32,7 @@ class MediaVolumeController @Inject constructor( delay(intervalMs) } - pauseMedia() + endPlayback(endAction) restoreVolume() } @@ -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() { diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt index 57e3ed4..527f6ac 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt @@ -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 @@ -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 @@ -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()) { @@ -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) { @@ -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) }, diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt index c4fec38..9618552 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt @@ -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 @@ -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) } } diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/MediaEndActionDialog.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/MediaEndActionDialog.kt new file mode 100644 index 0000000..f2f1fa5 --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/MediaEndActionDialog.kt @@ -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) + } + } +} diff --git a/feature/timer/src/main/res/values-de/strings.xml b/feature/timer/src/main/res/values-de/strings.xml index 8c134e2..ce6b17a 100644 --- a/feature/timer/src/main/res/values-de/strings.xml +++ b/feature/timer/src/main/res/values-de/strings.xml @@ -34,6 +34,12 @@ Sleep Timer Wiedergabe Medienwiedergabe stoppen + Pausieren oder stoppen + Aktion nach Ablauf wählen + Pausieren + Wiedergabe kann an derselben Stelle fortgesetzt werden + Stoppen + Wiedergabesitzung vollständig beenden Fade-out-Dauer %d Sekunden +/− Schrittweite diff --git a/feature/timer/src/main/res/values/strings.xml b/feature/timer/src/main/res/values/strings.xml index 8834e1d..edfedae 100644 --- a/feature/timer/src/main/res/values/strings.xml +++ b/feature/timer/src/main/res/values/strings.xml @@ -34,6 +34,12 @@ Sleep Timer Playback Stop audio and video playback + Pause or stop + Choose end action + Pause + Playback can be resumed where it left off + Stop + End the playback session completely Fade-out duration %d seconds +/− step