Skip to content
Merged
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
1 change: 0 additions & 1 deletion app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@
<ID>MagicNumber:DeviceWebview.kt$8000</ID>
<ID>MagicNumber:DeviceWebview.kt$CustomWebChromeClient$100.0f</ID>
<ID>MagicNumber:DeviceWebview.kt$WebViewNavigator$5</ID>
<ID>MagicNumber:SettingsViewModel.kt$SettingsViewModel$5000</ID>
<ID>MagicNumber:Theme.kt$3</ID>
<ID>MagicNumber:Theme.kt$4</ID>
<ID>MagicNumber:UpdateInstalling.kt$0xFF00b300</ID>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/assets/changelog/dev.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
### Sound Sync
Turn your phone into a live audio-reactive controller for your WLED devices. Capture sound from your microphone, and the app sends real-time frequency data and beat detection to all your WLED devices over UDP Sound Sync. Your LED strips will dance to the beat, no extra hardware required. Make sure your devices are set to Receive mode in their audio sync settings.
### Sound Sync (Beta)
Turn your phone into a live audio-reactive controller for your WLED devices. Capture sound from your microphone, and the app sends real-time frequency data and beat detection to all your WLED devices over UDP Sound Sync. Your LED strips will dance to the beat, no extra hardware required. Enable Sound Sync (Beta) in Settings to try it early, and make sure your devices are set to Receive mode in their audio sync settings.

### Other Improvements & Fixes
- Fixed the home screen widget's power and refresh buttons not working while Battery Saver is on.
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ val appModule: Module = module {
}

viewModelOf(::MainViewModel)
viewModelOf(::SettingsViewModel)
viewModel {
SettingsViewModel(
preferencesRepository = get(),
soundSyncManager = get(),
ioDispatcher = get(IO_DISPATCHER),
)
}
viewModelOf(::ChangelogViewModel)
viewModelOf(::ThemeViewModel)
viewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ fun DeviceListDetail(
val selectedDeviceMacAddress = navigator.currentDestination?.contentKey as? String
val selectedDevice = rememberSelectedDevice(devices, selectedDeviceMacAddress)

val showHiddenDevices by viewModel.showHiddenDevices.collectAsStateWithLifecycle()
val showSoundSync by deviceWebsocketListViewModel.soundSyncBetaEnabled.collectAsStateWithLifecycle()
val isWLEDCaptivePortal by viewModel.isWLEDCaptivePortal.collectAsStateWithLifecycle()
val isAddDeviceDialogVisible by viewModel.isAddDeviceDialogVisible.collectAsStateWithLifecycle()

Expand All @@ -102,7 +102,7 @@ fun DeviceListDetail(
DrawerSheetContent(
coroutineScope = coroutineScope,
drawerState = drawerState,
showHiddenDevices = showHiddenDevices,
showSoundSync = showSoundSync,
viewModel = viewModel,
actions = actions,
)
Expand All @@ -126,6 +126,7 @@ fun DeviceListDetail(
actions = DeviceListActions(
onItemClick = navigateToDetail,
onAddDevice = { viewModel.showAddDeviceDialog() },
onOpenSoundSync = actions.openSoundSync,
onShowHiddenDevices = { viewModel.toggleShowHiddenDevices() },
onRequestLocalNetworkAccess = localNetworkPermission.requestLocalNetworkAccess,
onRefresh = { viewModel.startDiscoveryServiceTimed() },
Expand Down Expand Up @@ -191,12 +192,14 @@ private fun rememberSelectedDevice(
private fun DrawerSheetContent(
coroutineScope: CoroutineScope,
drawerState: DrawerState,
showHiddenDevices: Boolean,
showSoundSync: Boolean,
viewModel: DeviceListDetailViewModel,
actions: DeviceListDetailActions,
) {
val showHiddenDevices by viewModel.showHiddenDevices.collectAsStateWithLifecycle()
DrawerContent(
showHiddenDevices = showHiddenDevices,
showSoundSync = showSoundSync,
actions = DrawerActions(
addDevice = {
coroutineScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ import ca.cgagnier.wlednativeandroid.R
import ca.cgagnier.wlednativeandroid.util.openUriSafely

@Composable
internal fun DrawerContent(showHiddenDevices: Boolean, actions: DrawerActions) {
internal fun DrawerContent(showHiddenDevices: Boolean, showSoundSync: Boolean = false, actions: DrawerActions) {
val uriHandler = LocalUriHandler.current
val scrollState = rememberScrollState()

Column(
modifier = Modifier.verticalScroll(scrollState),
) {
DrawerHeader()
DrawerMainActions(showHiddenDevices, actions)
DrawerMainActions(showHiddenDevices, showSoundSync, actions)
HorizontalDivider(modifier = Modifier.padding(12.dp))
DrawerExternalLinks(uriHandler)
Spacer(Modifier.height(24.dp))
Expand All @@ -65,7 +65,7 @@ private fun DrawerHeader() {
}

@Composable
private fun DrawerMainActions(showHiddenDevices: Boolean, actions: DrawerActions) {
private fun DrawerMainActions(showHiddenDevices: Boolean, showSoundSync: Boolean, actions: DrawerActions) {
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.add_a_device)) },
icon = {
Expand Down Expand Up @@ -103,18 +103,20 @@ private fun DrawerMainActions(showHiddenDevices: Boolean, actions: DrawerActions
onClick = actions.openSettings,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.sound_sync)) },
icon = {
Icon(
painter = painterResource(id = R.drawable.baseline_graphic_eq_24),
contentDescription = stringResource(R.string.sound_sync),
)
},
selected = false,
onClick = actions.openSoundSync,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
if (showSoundSync) {
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.sound_sync)) },
icon = {
Icon(
painter = painterResource(id = R.drawable.baseline_graphic_eq_24),
contentDescription = stringResource(R.string.sound_sync),
)
},
selected = false,
onClick = actions.openSoundSync,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBoxValue
Expand Down Expand Up @@ -65,6 +66,8 @@ data class DeviceListState(
val isLocalNetworkDenied: Boolean = false,
val showOfflineDevicesLast: Boolean = true,
val showHiddenDevices: Boolean = false,
val showSoundSync: Boolean = false,
val isSoundSyncRunning: Boolean = false,
val isInitialLoading: Boolean = false,
val currentTime: Long = 0L,
)
Expand All @@ -73,6 +76,7 @@ data class DeviceListActions(
val onItemClick: (DeviceWithState) -> Unit = {},
val onItemEdit: (DeviceWithState) -> Unit = {},
val onAddDevice: () -> Unit = {},
val onOpenSoundSync: () -> Unit = {},
val onShowHiddenDevices: () -> Unit = {},
val onRequestLocalNetworkAccess: () -> Unit = {},
val onRefresh: () -> Unit = {},
Expand All @@ -93,6 +97,8 @@ fun DeviceList(
val allDevices by viewModel.allDevicesWithState.collectAsStateWithLifecycle()
val showOfflineDevicesLast by viewModel.showOfflineDevicesLast.collectAsStateWithLifecycle()
val showHiddenDevices by viewModel.showHiddenDevices.collectAsStateWithLifecycle()
val showSoundSync by viewModel.soundSyncBetaEnabled.collectAsStateWithLifecycle()
val isSoundSyncRunning by viewModel.isSoundSyncRunning.collectAsStateWithLifecycle()

var isInitialLoading by rememberSaveable { mutableStateOf(true) }

Expand Down Expand Up @@ -123,6 +129,8 @@ fun DeviceList(
isLocalNetworkDenied = isLocalNetworkDenied,
showOfflineDevicesLast = showOfflineDevicesLast,
showHiddenDevices = showHiddenDevices,
showSoundSync = showSoundSync,
isSoundSyncRunning = isSoundSyncRunning,
isInitialLoading = isInitialLoading,
currentTime = currentTime,
),
Expand Down Expand Up @@ -167,8 +175,8 @@ fun DeviceListContent(state: DeviceListState, actions: DeviceListActions = Devic
Scaffold(
topBar = {
DeviceListAppBar(
onOpenDrawer = actions.onOpenDrawer,
onAddDevice = actions.onAddDevice,
state = state,
actions = actions,
)
},
) { innerPadding ->
Expand Down Expand Up @@ -412,21 +420,34 @@ fun LazyItemScope.DeviceRow(
}

@Composable
fun DeviceListAppBar(modifier: Modifier = Modifier, onOpenDrawer: () -> Unit, onAddDevice: () -> Unit) {
fun DeviceListAppBar(state: DeviceListState, actions: DeviceListActions, modifier: Modifier = Modifier) {
CenterAlignedTopAppBar(modifier = modifier, title = {
Image(
painter = painterResource(id = R.drawable.wled_logo_akemi),
contentDescription = stringResource(R.string.app_logo),
)
}, navigationIcon = {
IconButton(onClick = onOpenDrawer) {
IconButton(onClick = actions.onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = stringResource(R.string.description_menu_button),
)
}
}, actions = {
IconButton(onClick = onAddDevice) {
if (state.showSoundSync) {
IconButton(onClick = actions.onOpenSoundSync) {
Icon(
painter = painterResource(id = R.drawable.baseline_graphic_eq_24),
contentDescription = stringResource(R.string.sound_sync),
tint = if (state.isSoundSyncRunning) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
},
)
}
}
IconButton(onClick = actions.onAddDevice) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.add_a_device),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope
import ca.cgagnier.wlednativeandroid.model.Device
import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository
import ca.cgagnier.wlednativeandroid.service.DeviceConnectionManager
import ca.cgagnier.wlednativeandroid.service.soundsync.SoundSyncManager
import ca.cgagnier.wlednativeandroid.service.websocket.DeviceWithState
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

Expand All @@ -20,6 +22,7 @@ private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
class DeviceWebsocketListViewModel(
userPreferencesRepository: UserPreferencesRepository,
private val deviceConnectionManager: DeviceConnectionManager,
soundSyncManager: SoundSyncManager,
) : ViewModel() {

val allDevicesWithState: StateFlow<List<DeviceWithState>> =
Expand All @@ -39,6 +42,22 @@ class DeviceWebsocketListViewModel(
initialValue = false,
)

val soundSyncBetaEnabled: StateFlow<Boolean> =
userPreferencesRepository.soundSyncBetaEnabled.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = false,
)

val isSoundSyncRunning: StateFlow<Boolean> =
soundSyncManager.state
.map { it.isRunning }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
initialValue = soundSyncManager.state.value.isRunning,
)

val isPaused: StateFlow<Boolean> = deviceConnectionManager.isPaused

fun setBrightness(device: DeviceWithState, brightness: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ fun Settings(navigateUp: () -> Unit, viewModel: SettingsViewModel = koinViewMode
setAutoDiscover = { viewModel.setAutoDiscover(it) },
setShowOfflineDevicesLast = { viewModel.setShowOfflineDevicesLast(it) },
setTheme = { viewModel.setTheme(it) },
setSoundSyncBetaEnabled = { viewModel.setSoundSyncBetaEnabled(it) },
)
}
SettingsContent(
Expand All @@ -63,6 +64,7 @@ data class SettingsActions(
val setAutoDiscover: (Boolean) -> Unit = {},
val setShowOfflineDevicesLast: (Boolean) -> Unit = {},
val setTheme: (ThemeSettings) -> Unit = {},
val setSoundSyncBetaEnabled: (Boolean) -> Unit = {},
)

@Composable
Expand Down Expand Up @@ -106,6 +108,10 @@ fun SettingsContent(
currentTheme = settingsState.theme,
setTheme = actions.setTheme,
)
BetaOptions(
isSoundSyncBetaEnabled = settingsState.isSoundSyncBetaEnabled,
setSoundSyncBetaEnabled = actions.setSoundSyncBetaEnabled,
)
},
)
}
Expand Down Expand Up @@ -232,6 +238,25 @@ fun ThemeOptions(currentTheme: ThemeSettings, setTheme: (ThemeSettings) -> Unit
}
}

@Composable
fun BetaOptions(isSoundSyncBetaEnabled: Boolean, setSoundSyncBetaEnabled: (Boolean) -> Unit = {}) {
Card(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
stringResource(R.string.beta_features),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
SwitchRow(
label = stringResource(R.string.sound_sync_beta_setting),
checked = isSoundSyncBetaEnabled,
onCheckedChange = setSoundSyncBetaEnabled,
description = stringResource(R.string.sound_sync_beta_setting_description),
)
}
}
}

@Composable
fun OneOrTwoColumnLayout(
modifier: Modifier = Modifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,64 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ca.cgagnier.wlednativeandroid.repository.ThemeSettings
import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository
import ca.cgagnier.wlednativeandroid.service.soundsync.SoundSyncManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class SettingsViewModel(private val preferencesRepository: UserPreferencesRepository) : ViewModel() {
private const val STATE_FLOW_TIMEOUT_MS = 5000L

class SettingsViewModel(
private val preferencesRepository: UserPreferencesRepository,
private val soundSyncManager: SoundSyncManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {

private val theme = preferencesRepository.themeMode
private val autoDiscovery = preferencesRepository.autoDiscovery
private val showOfflineDevicesLast = preferencesRepository.showOfflineDevicesLast
private val showHiddenDevices = preferencesRepository.showHiddenDevices
private val soundSyncBetaEnabled = preferencesRepository.soundSyncBetaEnabled

val settingsState = combine(
autoDiscovery,
showOfflineDevicesLast,
showHiddenDevices,
theme,
) { autoDiscovery, showOfflineDevicesLast, showHiddenDevices, theme ->
soundSyncBetaEnabled,
) { autoDiscovery, showOfflineDevicesLast, showHiddenDevices, theme, soundSyncBetaEnabled ->
SettingsState(
isAutoDiscoveryEnabled = autoDiscovery,
showOfflineLast = showOfflineDevicesLast,
showHiddenDevices = showHiddenDevices,
theme = theme,
isSoundSyncBetaEnabled = soundSyncBetaEnabled,
)
}.stateIn(viewModelScope, WhileSubscribed(5000), SettingsState())
}.stateIn(viewModelScope, WhileSubscribed(STATE_FLOW_TIMEOUT_MS), SettingsState())

fun setAutoDiscover(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
fun setAutoDiscover(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
preferencesRepository.updateAutoDiscovery(enabled)
}
fun setShowOfflineDevicesLast(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
fun setShowOfflineDevicesLast(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
preferencesRepository.updateShowOfflineDeviceLast(enabled)
}
fun setShowHiddenDevices(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) {
fun setShowHiddenDevices(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
preferencesRepository.updateShowHiddenDevices(enabled)
}
fun setTheme(theme: ThemeSettings) = viewModelScope.launch(Dispatchers.IO) {
fun setTheme(theme: ThemeSettings) = viewModelScope.launch(ioDispatcher) {
preferencesRepository.updateThemeMode(theme)
}
fun setSoundSyncBetaEnabled(enabled: Boolean) {
if (!enabled) {
soundSyncManager.stop()
}
viewModelScope.launch(ioDispatcher) {
preferencesRepository.updateSoundSyncBetaEnabled(enabled)
}
}
}

@Stable
Expand All @@ -52,4 +71,5 @@ data class SettingsState(
val showOfflineLast: Boolean = true,
val showHiddenDevices: Boolean = false,
val theme: ThemeSettings = ThemeSettings.Auto,
val isSoundSyncBetaEnabled: Boolean = false,
)
Loading
Loading