feat(alarm): add option to only play alarm on headphones

Closes: #134
This commit is contained in:
Nishant Mishra
2025-12-04 10:58:16 +05:30
parent 5ced6d7c29
commit 3c86c2ce38
8 changed files with 147 additions and 55 deletions

View File

@@ -450,15 +450,19 @@ class TimerService : Service() {
}
private fun initializeMediaPlayer(): MediaPlayer? {
val settingsState = _settingsState.value
return try {
MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_ALARM)
.setUsage(
if (settingsState.mediaVolumeForAlarm) AudioAttributes.USAGE_MEDIA
else AudioAttributes.USAGE_ALARM
)
.build()
)
_settingsState.value.alarmSoundUri?.let {
settingsState.alarmSoundUri?.let {
setDataSource(applicationContext, it)
prepare()
}

View File

@@ -23,6 +23,7 @@ import androidx.annotation.StringRes
data class SettingsSwitchItem(
val checked: Boolean,
val enabled: Boolean = true,
val collapsible: Boolean = false,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int,
@param:StringRes val description: Int,

View File

@@ -25,6 +25,7 @@ import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -43,6 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LargeFlexibleTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
@@ -60,8 +62,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.nsh07.pomodoro.R
@@ -74,6 +78,7 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@@ -127,25 +132,36 @@ fun AlarmSettings(
}
val switchItems = remember(
settingsState.blackTheme,
settingsState.aodEnabled,
settingsState.alarmEnabled,
settingsState.vibrateEnabled
settingsState.vibrateEnabled,
settingsState.mediaVolumeForAlarm
) {
listOf(
SettingsSwitchItem(
checked = settingsState.alarmEnabled,
icon = R.drawable.alarm_on,
label = R.string.sound,
description = R.string.alarm_desc,
onClick = { onAction(SettingsAction.SaveAlarmEnabled(it)) }
listOf(
SettingsSwitchItem(
checked = settingsState.alarmEnabled,
icon = R.drawable.alarm_on,
label = R.string.sound,
description = R.string.alarm_desc,
onClick = { onAction(SettingsAction.SaveAlarmEnabled(it)) }
),
SettingsSwitchItem(
checked = settingsState.vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = R.string.vibrate,
description = R.string.vibrate_desc,
onClick = { onAction(SettingsAction.SaveVibrateEnabled(it)) }
)
),
SettingsSwitchItem(
checked = settingsState.vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = R.string.vibrate,
description = R.string.vibrate_desc,
onClick = { onAction(SettingsAction.SaveVibrateEnabled(it)) }
listOf(
SettingsSwitchItem(
checked = settingsState.mediaVolumeForAlarm,
collapsible = true,
icon = R.drawable.music_note,
label = R.string.media_volume_for_alarm,
description = R.string.media_volume_for_alarm_desc,
onClick = { onAction(SettingsAction.SaveMediaVolumeForAlarm(it)) }
)
)
)
}
@@ -203,44 +219,65 @@ fun AlarmSettings(
.clickable(onClick = { ringtonePickerLauncher.launch(ringtonePickerIntent) })
)
}
itemsIndexed(switchItems) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(stringResource(item.description)) },
trailingContent = {
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
thumbContent = {
if (item.checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when (index) {
switchItems.lastIndex -> bottomListItemShape
else -> middleListItemShape
switchItems.fastForEach { items ->
itemsIndexed(items) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(stringResource(item.label)) },
supportingContent = {
if (item.collapsible) {
var expanded by remember { mutableStateOf(false) }
Text(
stringResource(item.description),
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clickable { expanded = !expanded }
.animateContentSize(motionScheme.defaultSpatialSpec())
)
} else {
Text(stringResource(item.description))
}
)
)
},
trailingContent = {
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
thumbContent = {
if (item.checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when {
items.size == 1 -> cardShape
index == items.lastIndex -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
item {
Spacer(Modifier.height(12.dp))
}
}
item { Spacer(Modifier.height(12.dp)) }

View File

@@ -26,6 +26,7 @@ sealed interface SettingsAction {
data class SaveBlackTheme(val enabled: Boolean) : SettingsAction
data class SaveAodEnabled(val enabled: Boolean) : SettingsAction
data class SaveDndEnabled(val enabled: Boolean) : SettingsAction
data class SaveMediaVolumeForAlarm(val enabled: Boolean) : SettingsAction
data class SaveAlarmSound(val uri: Uri?) : SettingsAction
data class SaveTheme(val theme: String) : SettingsAction
data class SaveColorScheme(val color: Color) : SettingsAction

View File

@@ -31,6 +31,7 @@ data class SettingsState(
val alarmEnabled: Boolean = true,
val vibrateEnabled: Boolean = true,
val dndEnabled: Boolean = false,
val mediaVolumeForAlarm: Boolean = false,
val focusTime: Long = 25 * 60 * 1000L,
val shortBreakTime: Long = 5 * 60 * 1000L,

View File

@@ -109,6 +109,7 @@ class SettingsViewModel(
is SettingsAction.SaveAlarmEnabled -> saveAlarmEnabled(action.enabled)
is SettingsAction.SaveVibrateEnabled -> saveVibrateEnabled(action.enabled)
is SettingsAction.SaveDndEnabled -> saveDndEnabled(action.enabled)
is SettingsAction.SaveMediaVolumeForAlarm -> saveMediaVolumeForAlarm(action.enabled)
is SettingsAction.SaveColorScheme -> saveColorScheme(action.color)
is SettingsAction.SaveTheme -> saveTheme(action.theme)
is SettingsAction.SaveBlackTheme -> saveBlackTheme(action.enabled)
@@ -260,6 +261,18 @@ class SettingsViewModel(
}
}
private fun saveMediaVolumeForAlarm(mediaVolumeForAlarm: Boolean) {
viewModelScope.launch {
_settingsState.update { currentState ->
currentState.copy(mediaVolumeForAlarm = mediaVolumeForAlarm)
}
preferenceRepository.saveBooleanPreference(
"media_volume_for_alarm",
mediaVolumeForAlarm
)
}
}
suspend fun reloadSettings() {
var settingsState = _settingsState.value
val focusTime =
@@ -316,6 +329,12 @@ class SettingsViewModel(
)
val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled")
?: preferenceRepository.saveBooleanPreference("dnd_enabled", settingsState.dndEnabled)
val mediaVolumeForAlarm =
preferenceRepository.getBooleanPreference("media_volume_for_alarm")
?: preferenceRepository.saveBooleanPreference(
"media_volume_for_alarm",
settingsState.mediaVolumeForAlarm
)
_settingsState.update { currentState ->
currentState.copy(
@@ -330,7 +349,8 @@ class SettingsViewModel(
aodEnabled = aodEnabled,
alarmEnabled = alarmEnabled,
vibrateEnabled = vibrateEnabled,
dndEnabled = dndEnabled
dndEnabled = dndEnabled,
mediaVolumeForAlarm = mediaVolumeForAlarm
)
}

View File

@@ -0,0 +1,26 @@
<!--
~ Copyright (c) 2025 Nishant Mishra
~
~ This file is part of Tomato - a minimalist pomodoro timer for Android.
~
~ Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
~ General Public License as published by the Free Software Foundation, either version 3 of the
~ License, or (at your option) any later version.
~
~ Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Tomato.
~ If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e3e3e3"
android:pathData="M400,840q-66,0 -113,-47t-47,-113q0,-66 47,-113t113,-47q23,0 42.5,5.5T480,542v-382q0,-17 11.5,-28.5T520,120h160q17,0 28.5,11.5T720,160v80q0,17 -11.5,28.5T680,280L560,280v400q0,66 -47,113t-113,47Z" />
</vector>

View File

@@ -103,4 +103,6 @@
<string name="bmc_desc">Support me with a small donation</string>
<string name="tomato_plus_desc">Customize further with Tomato+</string>
<string name="license">License</string>
<string name="media_volume_for_alarm">Headphone mode</string>
<string name="media_volume_for_alarm_desc">Plays on headphones only. If headphones are disconnected, alarm plays through speaker at media volume.</string>
</resources>