diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index ce1f722c..2923b40c 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -130,7 +130,6 @@
MagicNumber:DeviceWebview.kt$8000
MagicNumber:DeviceWebview.kt$CustomWebChromeClient$100.0f
MagicNumber:DeviceWebview.kt$WebViewNavigator$5
- MagicNumber:SettingsViewModel.kt$SettingsViewModel$5000
MagicNumber:Theme.kt$3
MagicNumber:Theme.kt$4
MagicNumber:UpdateInstalling.kt$0xFF00b300
diff --git a/app/src/main/assets/changelog/dev.md b/app/src/main/assets/changelog/dev.md
index 4e2f6553..1ddc3dd9 100644
--- a/app/src/main/assets/changelog/dev.md
+++ b/app/src/main/assets/changelog/dev.md
@@ -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.
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/di/AppModule.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/di/AppModule.kt
index 6ab5e660..1accb7c2 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/di/AppModule.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/di/AppModule.kt
@@ -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 {
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DeviceListDetail.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DeviceListDetail.kt
index 964a136c..691e7a96 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DeviceListDetail.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DeviceListDetail.kt
@@ -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()
@@ -102,7 +102,7 @@ fun DeviceListDetail(
DrawerSheetContent(
coroutineScope = coroutineScope,
drawerState = drawerState,
- showHiddenDevices = showHiddenDevices,
+ showSoundSync = showSoundSync,
viewModel = viewModel,
actions = actions,
)
@@ -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() },
@@ -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 {
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DrawerContent.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DrawerContent.kt
index 35034ead..7145448c 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DrawerContent.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/DrawerContent.kt
@@ -33,7 +33,7 @@ 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()
@@ -41,7 +41,7 @@ internal fun DrawerContent(showHiddenDevices: Boolean, actions: DrawerActions) {
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))
@@ -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 = {
@@ -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
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceList.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceList.kt
index b573c09c..2aafe31b 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceList.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceList.kt
@@ -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
@@ -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,
)
@@ -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 = {},
@@ -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) }
@@ -123,6 +129,8 @@ fun DeviceList(
isLocalNetworkDenied = isLocalNetworkDenied,
showOfflineDevicesLast = showOfflineDevicesLast,
showHiddenDevices = showHiddenDevices,
+ showSoundSync = showSoundSync,
+ isSoundSyncRunning = isSoundSyncRunning,
isInitialLoading = isInitialLoading,
currentTime = currentTime,
),
@@ -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 ->
@@ -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),
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt
index dc7b0281..7f46d26d 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt
@@ -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
@@ -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> =
@@ -39,6 +42,22 @@ class DeviceWebsocketListViewModel(
initialValue = false,
)
+ val soundSyncBetaEnabled: StateFlow =
+ userPreferencesRepository.soundSyncBetaEnabled.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
+ initialValue = false,
+ )
+
+ val isSoundSyncRunning: StateFlow =
+ soundSyncManager.state
+ .map { it.isRunning }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS),
+ initialValue = soundSyncManager.state.value.isRunning,
+ )
+
val isPaused: StateFlow = deviceConnectionManager.isPaused
fun setBrightness(device: DeviceWithState, brightness: Int) {
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/Settings.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/Settings.kt
index d9e709c8..a4a12b9a 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/Settings.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/Settings.kt
@@ -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(
@@ -63,6 +64,7 @@ data class SettingsActions(
val setAutoDiscover: (Boolean) -> Unit = {},
val setShowOfflineDevicesLast: (Boolean) -> Unit = {},
val setTheme: (ThemeSettings) -> Unit = {},
+ val setSoundSyncBetaEnabled: (Boolean) -> Unit = {},
)
@Composable
@@ -106,6 +108,10 @@ fun SettingsContent(
currentTheme = settingsState.theme,
setTheme = actions.setTheme,
)
+ BetaOptions(
+ isSoundSyncBetaEnabled = settingsState.isSoundSyncBetaEnabled,
+ setSoundSyncBetaEnabled = actions.setSoundSyncBetaEnabled,
+ )
},
)
}
@@ -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,
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModel.kt
index 0f38a7c6..383550ef 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModel.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModel.kt
@@ -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
@@ -52,4 +71,5 @@ data class SettingsState(
val showOfflineLast: Boolean = true,
val showHiddenDevices: Boolean = false,
val theme: ThemeSettings = ThemeSettings.Auto,
+ val isSoundSyncBetaEnabled: Boolean = false,
)
diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/soundsync/SoundSyncScreen.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/soundsync/SoundSyncScreen.kt
index 0faccb74..70d71965 100644
--- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/soundsync/SoundSyncScreen.kt
+++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/soundsync/SoundSyncScreen.kt
@@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -94,19 +95,7 @@ fun SoundSyncScreen(navigateUp: () -> Unit, viewModel: SoundSyncViewModel = koin
}
Scaffold(
- topBar = {
- CenterAlignedTopAppBar(
- title = { Text(stringResource(R.string.sound_sync)) },
- navigationIcon = {
- IconButton(onClick = navigateUp) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.description_back_button),
- )
- }
- },
- )
- },
+ topBar = { SoundSyncTopBar(navigateUp = navigateUp) },
) { innerPadding ->
SoundSyncContent(
uiState = uiState,
@@ -124,6 +113,37 @@ fun SoundSyncScreen(navigateUp: () -> Unit, viewModel: SoundSyncViewModel = koin
}
}
+@Composable
+private fun SoundSyncTopBar(navigateUp: () -> Unit) {
+ CenterAlignedTopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.sound_sync))
+ Surface(
+ modifier = Modifier.padding(start = 8.dp),
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ ) {
+ Text(
+ text = stringResource(R.string.beta_badge),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = navigateUp) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.description_back_button),
+ )
+ }
+ },
+ )
+}
+
@Composable
private fun SoundSyncContent(
uiState: SoundSyncUiState,
@@ -181,11 +201,18 @@ private fun DescriptionCard() {
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
- Text(
- text = stringResource(R.string.sound_sync_description),
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(start = 12.dp),
- )
+ Column(modifier = Modifier.padding(start = 12.dp)) {
+ Text(
+ text = stringResource(R.string.sound_sync_description),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = stringResource(R.string.sound_sync_beta_notice),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 51f49fae..9605c934 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -168,5 +168,10 @@
Wird angezeigt, wenn Sound Sync aktiv ist
Mikrofonberechtigung ist erforderlich
Audioaufnahme konnte nicht gestartet werden.
+ Beta-Funktionen
+ BETA
+ Sound Sync (Beta)
+ Aktiviere die Sound-Sync-Beta, um diese Funktion vorab auszuprobieren.
+ Diese Funktion befindet sich derzeit in der Beta-Phase und kann sich in zukünftigen Updates ändern.
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 456441ad..039a9efc 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -168,5 +168,10 @@
S\'affiche lorsque la synchronisation sonore est active
L\'autorisation d\'accès au microphone est requise
Échec du démarrage de la capture audio.
+ Fonctionnalités bêta
+ BÊTA
+ Synchro Sonore (Bêta)
+ Activez la bêta de la synchronisation sonore pour essayer cette fonctionnalité en avant-première.
+ Cette fonctionnalité est actuellement en bêta et peut évoluer dans les prochaines mises à jour.
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 1d389249..71ed08df 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -166,5 +166,10 @@
在声音同步处于活动状态时显示
需要麦克风录音权限
无法启动音频采集。
+ 测试版功能
+ 测试版
+ 声音同步(测试版)
+ 启用声音同步测试版以提前体验此功能。
+ 此功能目前处于测试阶段,可能会在未来的更新中发生变化。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b8731c06..74495fd5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -169,5 +169,10 @@
Shows when sound sync is active
Record audio permission is required
Failed to start audio capture.
+ Beta features
+ BETA
+ Sound Sync (Beta)
+ Activate the sound sync beta to try this feature early.
+ This feature is currently in beta and may change in future updates.
diff --git a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsDarkPreview_4d30ee2f_0.png b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsDarkPreview_4d30ee2f_0.png
index ba9d8982..52b00905 100644
Binary files a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsDarkPreview_4d30ee2f_0.png and b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsDarkPreview_4d30ee2f_0.png differ
diff --git a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsLightPreview_748aa731_0.png b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsLightPreview_748aa731_0.png
index ee5f6555..157155be 100644
Binary files a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsLightPreview_748aa731_0.png and b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsLightPreview_748aa731_0.png differ
diff --git a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsTwoColumnPreview_ed25ed81_0.png b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsTwoColumnPreview_ed25ed81_0.png
index 9684b407..dd0fb97e 100644
Binary files a/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsTwoColumnPreview_ed25ed81_0.png and b/app/src/screenshotTestDefaultDebug/reference/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsScreenshotTest/SettingsTwoColumnPreview_ed25ed81_0.png differ
diff --git a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/UserPreferencesSerializerTest.ss b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/UserPreferencesSerializerTest.ss
index f1a92698..6c82167c 100644
--- a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/UserPreferencesSerializerTest.ss
+++ b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/UserPreferencesSerializerTest.ss
@@ -13,7 +13,8 @@
"showHiddenDevices": false,
"lastChangelogVersionSeen": "",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ readFrom_partialJson_usesDefaultValuesForMissingFields ═╗
{
@@ -30,7 +31,8 @@
"showHiddenDevices": false,
"lastChangelogVersionSeen": "",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ readFrom_unknownKeys_ignoresThemGracefully ═╗
{
@@ -47,7 +49,8 @@
"showHiddenDevices": false,
"lastChangelogVersionSeen": "",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ roundTrip_serializesAndDeserializesCorrectly ═╗
{
@@ -64,6 +67,7 @@
"showHiddenDevices": true,
"lastChangelogVersionSeen": "1.5.0",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ [end of file] ═╗
diff --git a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/LegacyProtoToKotlinxPreferencesMigrationTest.ss b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/LegacyProtoToKotlinxPreferencesMigrationTest.ss
index b04fcb56..f103bb73 100644
--- a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/LegacyProtoToKotlinxPreferencesMigrationTest.ss
+++ b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/LegacyProtoToKotlinxPreferencesMigrationTest.ss
@@ -13,7 +13,8 @@
"showHiddenDevices": true,
"lastChangelogVersionSeen": "2.1.0",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ migrate_withV0LegacyProto_setsExpectedDefaultsAndResetsDataSharingFlags ═╗
{
@@ -30,6 +31,7 @@
"showHiddenDevices": false,
"lastChangelogVersionSeen": "7.0.1",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ [end of file] ═╗
diff --git a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/UserPreferencesV0ToV1Test.ss b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/UserPreferencesV0ToV1Test.ss
index 9f473de7..73b8b810 100644
--- a/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/UserPreferencesV0ToV1Test.ss
+++ b/app/src/test/java/ca/cgagnier/wlednativeandroid/repository/migrations/UserPreferencesV0ToV1Test.ss
@@ -13,6 +13,7 @@
"showHiddenDevices": false,
"lastChangelogVersionSeen": "",
"soundSyncGain": 1.0,
- "soundSyncAgcEnabled": false
+ "soundSyncAgcEnabled": false,
+ "soundSyncBetaEnabled": false
}
╔═ [end of file] ═╗
diff --git a/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModelTest.kt b/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModelTest.kt
index e299b4b0..b76137af 100644
--- a/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModelTest.kt
+++ b/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModelTest.kt
@@ -3,6 +3,8 @@ package ca.cgagnier.wlednativeandroid.ui.homeScreen.list
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.soundsync.SoundSyncState
import ca.cgagnier.wlednativeandroid.service.websocket.DeviceWithState
import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketStatus
import io.mockk.coEvery
@@ -13,8 +15,11 @@ import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -31,9 +36,12 @@ class DeviceWebsocketListViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val userPreferencesRepository: UserPreferencesRepository = mockk(relaxed = true)
private val deviceConnectionManager: DeviceConnectionManager = mockk(relaxed = true)
+ private val soundSyncManager: SoundSyncManager = mockk(relaxed = true)
private val allDevicesWithStateFlow = MutableStateFlow>(emptyList())
private val isPausedFlow = MutableStateFlow(false)
+ private val soundSyncBetaEnabledFlow = MutableStateFlow(false)
+ private val soundSyncStateFlow = MutableStateFlow(SoundSyncState())
private val sampleDevice = Device(
macAddress = "AA:BB:CC:DD:EE:01",
@@ -50,8 +58,10 @@ class DeviceWebsocketListViewModelTest {
Dispatchers.setMain(testDispatcher)
every { userPreferencesRepository.showOfflineDevicesLast } returns flowOf(true)
every { userPreferencesRepository.showHiddenDevices } returns flowOf(false)
+ every { userPreferencesRepository.soundSyncBetaEnabled } returns soundSyncBetaEnabledFlow
every { deviceConnectionManager.allDevicesWithState } returns allDevicesWithStateFlow
every { deviceConnectionManager.isPaused } returns isPausedFlow
+ every { soundSyncManager.state } returns soundSyncStateFlow
}
@AfterEach
@@ -62,6 +72,7 @@ class DeviceWebsocketListViewModelTest {
private fun createViewModel(): DeviceWebsocketListViewModel = DeviceWebsocketListViewModel(
userPreferencesRepository = userPreferencesRepository,
deviceConnectionManager = deviceConnectionManager,
+ soundSyncManager = soundSyncManager,
)
@Test
@@ -121,4 +132,30 @@ class DeviceWebsocketListViewModelTest {
coVerify { deviceConnectionManager.deleteDevice(sampleDevice) }
}
+
+ @Test
+ fun `soundSyncBetaEnabled reflects preferences repository flow`() = runTest(testDispatcher) {
+ val viewModel = createViewModel()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.soundSyncBetaEnabled.collect()
+ }
+
+ soundSyncBetaEnabledFlow.value = true
+ advanceUntilIdle()
+
+ assertTrue(viewModel.soundSyncBetaEnabled.value)
+ }
+
+ @Test
+ fun `isSoundSyncRunning reflects sound sync manager state`() = runTest(testDispatcher) {
+ val viewModel = createViewModel()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.isSoundSyncRunning.collect()
+ }
+
+ soundSyncStateFlow.value = SoundSyncState(isRunning = true)
+ advanceUntilIdle()
+
+ assertTrue(viewModel.isSoundSyncRunning.value)
+ }
}
diff --git a/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModelTest.kt b/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModelTest.kt
new file mode 100644
index 00000000..9a83c372
--- /dev/null
+++ b/app/src/test/java/ca/cgagnier/wlednativeandroid/ui/settingsScreen/SettingsViewModelTest.kt
@@ -0,0 +1,93 @@
+package ca.cgagnier.wlednativeandroid.ui.settingsScreen
+
+import ca.cgagnier.wlednativeandroid.repository.ThemeSettings
+import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository
+import ca.cgagnier.wlednativeandroid.service.soundsync.SoundSyncManager
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SettingsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val preferencesRepository: UserPreferencesRepository = mockk(relaxed = true)
+ private val soundSyncManager: SoundSyncManager = mockk(relaxed = true)
+ private val soundSyncBetaEnabledFlow = MutableStateFlow(false)
+
+ @BeforeEach
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { preferencesRepository.themeMode } returns flowOf(ThemeSettings.Auto)
+ every { preferencesRepository.autoDiscovery } returns flowOf(true)
+ every { preferencesRepository.showOfflineDevicesLast } returns flowOf(true)
+ every { preferencesRepository.showHiddenDevices } returns flowOf(false)
+ every { preferencesRepository.soundSyncBetaEnabled } returns soundSyncBetaEnabledFlow
+ }
+
+ @AfterEach
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel(): SettingsViewModel = SettingsViewModel(
+ preferencesRepository = preferencesRepository,
+ soundSyncManager = soundSyncManager,
+ ioDispatcher = testDispatcher,
+ )
+
+ @Test
+ fun `settingsState reflects soundSyncBetaEnabled flow`() = runTest(testDispatcher) {
+ val viewModel = createViewModel()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ viewModel.settingsState.collect()
+ }
+
+ assertFalse(viewModel.settingsState.value.isSoundSyncBetaEnabled)
+
+ soundSyncBetaEnabledFlow.value = true
+ advanceUntilIdle()
+
+ assertTrue(viewModel.settingsState.value.isSoundSyncBetaEnabled)
+ }
+
+ @Test
+ fun `setSoundSyncBetaEnabled true updates preference without stopping sound sync`() = runTest(testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.setSoundSyncBetaEnabled(true)
+ advanceUntilIdle()
+
+ verify(exactly = 0) { soundSyncManager.stop() }
+ coVerify { preferencesRepository.updateSoundSyncBetaEnabled(true) }
+ }
+
+ @Test
+ fun `setSoundSyncBetaEnabled false stops sound sync and updates preference`() = runTest(testDispatcher) {
+ val viewModel = createViewModel()
+
+ viewModel.setSoundSyncBetaEnabled(false)
+ advanceUntilIdle()
+
+ verify(exactly = 1) { soundSyncManager.stop() }
+ coVerify { preferencesRepository.updateSoundSyncBetaEnabled(false) }
+ }
+}
diff --git a/iosApp/wled/Localizable.xcstrings b/iosApp/wled/Localizable.xcstrings
index b454a05f..395769c6 100644
--- a/iosApp/wled/Localizable.xcstrings
+++ b/iosApp/wled/Localizable.xcstrings
@@ -273,6 +273,35 @@
}
}
},
+ "Activate the sound sync beta to try this feature early." : {
+ "comment" : "Footer description for the Sound Sync beta setting.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aktiviere die Sound-Sync-Beta, um diese Funktion vorab auszuprobieren."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activate the sound sync beta to try this feature early."
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Activez la bêta de la synchronisation sonore pour essayer cette fonctionnalité en avant-première."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "启用声音同步测试版以提前体验此功能。"
+ }
+ }
+ }
+ },
"Add" : {
"comment" : "The label of a button that adds a new device.",
"isCommentAutoGenerated" : true,
@@ -598,6 +627,64 @@
}
}
},
+ "BETA" : {
+ "comment" : "Uppercase badge label indicating a beta feature.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "BETA"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "BETA"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "BÊTA"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "测试版"
+ }
+ }
+ }
+ },
+ "Beta Features" : {
+ "comment" : "Section header in Settings for beta features.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beta-Funktionen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beta Features"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fonctionnalités bêta"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "测试版功能"
+ }
+ }
+ }
+ },
"Brightness (%)" : {
"comment" : "Label for the brightness percentage parameter in the Set WLED Brightness intent.",
"isCommentAutoGenerated" : true,
@@ -2759,6 +2846,35 @@
}
}
},
+ "Sound Sync (Beta)" : {
+ "comment" : "Toggle label in Settings to enable the Sound Sync beta feature.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sound Sync (Beta)"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sound Sync (Beta)"
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Synchronisation Sonore (Bêta)"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "声音同步(测试版)"
+ }
+ }
+ }
+ },
"Sound sync error: %@" : {
"comment" : "Error message format for Sound Sync. %@ is the error detail.",
"localizations" : {
@@ -3138,6 +3254,35 @@
}
}
},
+ "This feature is currently in beta and may change in future updates." : {
+ "comment" : "Notice shown on the Sound Sync screen explaining that the feature is in beta.",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Diese Funktion befindet sich derzeit in der Beta-Phase und kann sich in zukünftigen Updates ändern."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This feature is currently in beta and may change in future updates."
+ }
+ },
+ "fr-CA" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cette fonctionnalité est actuellement en bêta et peut évoluer dans les prochaines mises à jour."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "此功能目前处于测试阶段,可能会在未来的更新中发生变化。"
+ }
+ }
+ }
+ },
"Toggle Power" : {
"comment" : "A label for the button that toggles the device's power.",
"isCommentAutoGenerated" : true,
diff --git a/iosApp/wled/View/DeviceListView.swift b/iosApp/wled/View/DeviceListView.swift
index 4cc0ec93..de46217f 100644
--- a/iosApp/wled/View/DeviceListView.swift
+++ b/iosApp/wled/View/DeviceListView.swift
@@ -85,6 +85,7 @@ struct DeviceListView: View {
showOfflineDevicesLast: $viewModel.showOfflineDevicesLast,
theme: $viewModel.theme,
autoDiscovery: $viewModel.autoDiscovery,
+ soundSyncBetaEnabled: $viewModel.soundSyncBetaEnabled,
onShowChangelog: {
pendingShowChangelog = true
showSettingsSheet = false
@@ -138,6 +139,13 @@ struct DeviceListView: View {
.onChange(of: viewModel.theme, initial: true) { _, newTheme in
newTheme.applyToWindows()
}
+ .onChange(of: viewModel.soundSyncBetaEnabled) { _, isEnabled in
+ if !isEnabled, let soundSyncViewModel {
+ Task {
+ await soundSyncViewModel.stop()
+ }
+ }
+ }
.task {
async let observeTask: Void = viewModel.observe()
async let changelogTask: Void = changelogViewModel.checkChangelog()
@@ -322,21 +330,23 @@ private extension DeviceListView {
}
// 2. Sound Sync Button
- Button {
- if soundSyncViewModel == nil {
- soundSyncViewModel = SoundSyncViewModel(
- userPreferencesRepository: database.userPreferencesRepository,
- deviceRepository: database.deviceRepository
+ if viewModel.soundSyncBetaEnabled {
+ Button {
+ if soundSyncViewModel == nil {
+ soundSyncViewModel = SoundSyncViewModel(
+ userPreferencesRepository: database.userPreferencesRepository,
+ deviceRepository: database.deviceRepository
+ )
+ }
+ showSoundSyncSheet.toggle()
+ } label: {
+ let isSoundSyncRunning = soundSyncViewModel?.isRunning == true
+ Label(
+ "Sound Sync",
+ systemImage: isSoundSyncRunning ? "waveform.badge.mic" : "waveform"
)
+ .symbolEffect(.variableColor.iterative, isActive: isSoundSyncRunning)
}
- showSoundSyncSheet.toggle()
- } label: {
- let isSoundSyncRunning = soundSyncViewModel?.isRunning == true
- Label(
- "Sound Sync",
- systemImage: isSoundSyncRunning ? "waveform.badge.mic" : "waveform"
- )
- .symbolEffect(.variableColor.iterative, isActive: isSoundSyncRunning)
}
// 3. Settings Button
diff --git a/iosApp/wled/View/Settings/Settings.swift b/iosApp/wled/View/Settings/Settings.swift
index ff3d162d..8eba4a35 100644
--- a/iosApp/wled/View/Settings/Settings.swift
+++ b/iosApp/wled/View/Settings/Settings.swift
@@ -13,6 +13,7 @@ struct Settings: View {
@Binding var showOfflineDevicesLast: Bool
@Binding var theme: ThemeSettings
@Binding var autoDiscovery: Bool
+ @Binding var soundSyncBetaEnabled: Bool
var onShowChangelog: (() -> Void)?
/// Injectable so snapshot tests don't change with every release.
var appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
@@ -53,6 +54,16 @@ struct Settings: View {
.labelsHidden()
}
+ Section {
+ Toggle(isOn: $soundSyncBetaEnabled) {
+ Label("Sound Sync (Beta)", systemImage: "waveform")
+ }
+ } header: {
+ Text("Beta Features")
+ } footer: {
+ Text("Activate the sound sync beta to try this feature early.")
+ }
+
Section {
if let onShowChangelog {
Button {
@@ -97,6 +108,7 @@ struct Settings: View {
showHiddenDevices: .constant(true),
showOfflineDevicesLast: .constant(true),
theme: .constant(.auto),
- autoDiscovery: .constant(true)
+ autoDiscovery: .constant(true),
+ soundSyncBetaEnabled: .constant(false)
)
}
diff --git a/iosApp/wled/View/SoundSync/SoundSyncView.swift b/iosApp/wled/View/SoundSync/SoundSyncView.swift
index 54f8ddff..3677a531 100644
--- a/iosApp/wled/View/SoundSync/SoundSyncView.swift
+++ b/iosApp/wled/View/SoundSync/SoundSyncView.swift
@@ -30,6 +30,19 @@ struct SoundSyncView: View {
.navigationTitle("Sound Sync")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+ ToolbarItem(placement: .principal) {
+ HStack(spacing: 6) {
+ Text("Sound Sync")
+ .font(.headline)
+ Text("BETA")
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.accentColor.opacity(0.15))
+ .foregroundStyle(.tint)
+ .clipShape(Capsule())
+ }
+ }
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
@@ -55,8 +68,13 @@ struct SoundSyncView: View {
HStack(spacing: 12) {
Image(systemName: "waveform")
.foregroundStyle(.tint)
- Text("Send audio-reactive data to your WLED devices using UDP Sound Sync. Devices must be set to Receive mode in their audio sync settings.")
- .font(.subheadline)
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Send audio-reactive data to your WLED devices using UDP Sound Sync. Devices must be set to Receive mode in their audio sync settings.")
+ .font(.subheadline)
+ Text("This feature is currently in beta and may change in future updates.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
}
}
}
diff --git a/iosApp/wled/ViewModel/DeviceWebsocketListViewModel.swift b/iosApp/wled/ViewModel/DeviceWebsocketListViewModel.swift
index 6c66677b..30318fc4 100644
--- a/iosApp/wled/ViewModel/DeviceWebsocketListViewModel.swift
+++ b/iosApp/wled/ViewModel/DeviceWebsocketListViewModel.swift
@@ -70,6 +70,14 @@ final class DeviceWebsocketListViewModel {
}
}
+ var soundSyncBetaEnabled: Bool = false {
+ didSet {
+ guard soundSyncBetaEnabled != oldValue, !isSyncingPreferences else { return }
+ let enabled = soundSyncBetaEnabled
+ persistPreference { try await $0.updateSoundSyncBetaEnabled(enabled: enabled) }
+ }
+ }
+
// MARK: - Private Properties
private let connectionManager: DeviceConnectionManager
@@ -123,6 +131,7 @@ final class DeviceWebsocketListViewModel {
group.addTask { await self.observeShowOfflineDevicesLast() }
group.addTask { await self.observeThemeMode() }
group.addTask { await self.observeAutoDiscovery() }
+ group.addTask { await self.observeSoundSyncBetaEnabled() }
group.addTask { await self.observePausedState() }
group.addTask { await self.runOfflineGracePeriodTimer() }
}
@@ -154,6 +163,12 @@ final class DeviceWebsocketListViewModel {
await observePreference(userPreferencesRepository.autoDiscovery) { autoDiscovery = $0.boolValue }
}
+ private func observeSoundSyncBetaEnabled() async {
+ await observePreference(userPreferencesRepository.soundSyncBetaEnabled) {
+ soundSyncBetaEnabled = $0.boolValue
+ }
+ }
+
/// Mirrors a persisted preference into a local property without echoing the change back to the repository.
private func observePreference(
_ preference: some AsyncSequence,
diff --git a/iosApp/wled/changelog/dev.md b/iosApp/wled/changelog/dev.md
index 09859d28..6e517f86 100644
--- a/iosApp/wled/changelog/dev.md
+++ b/iosApp/wled/changelog/dev.md
@@ -1,2 +1,2 @@
-### Sound Sync
-Turn your WLED devices into a music visualizer. Sound Sync captures audio from your microphone and sends real-time frequency data to your devices over the local network using UDP Sound Sync. Adjust the gain, enable automatic gain control, and watch the live frequency bars react to the sound around you.
+### Sound Sync (Beta)
+Turn your WLED devices into a music visualizer. Sound Sync captures audio from your microphone and sends real-time frequency data to your devices over the local network using UDP Sound Sync. Adjust the gain, enable automatic gain control, and watch the live frequency bars react to the sound around you. Enable Sound Sync (Beta) in Settings to try it early.
diff --git a/iosApp/wledTests/DeviceWebsocketListViewModelTests.swift b/iosApp/wledTests/DeviceWebsocketListViewModelTests.swift
index 42d25f73..c44a2469 100644
--- a/iosApp/wledTests/DeviceWebsocketListViewModelTests.swift
+++ b/iosApp/wledTests/DeviceWebsocketListViewModelTests.swift
@@ -281,6 +281,7 @@ struct DeviceWebsocketListViewModelTests {
viewModel.showOfflineDevicesLast = false
viewModel.theme = .dark
viewModel.autoDiscovery = false
+ viewModel.soundSyncBetaEnabled = true
try await waitUntil {
let preferences = database.userPreferencesRepository
@@ -288,7 +289,12 @@ struct DeviceWebsocketListViewModelTests {
let showOfflineLast = await preferences.showOfflineDevicesLast.firstValue()?.boolValue
let currentTheme = await preferences.themeMode.firstValue()
let autoDiscovery = await preferences.autoDiscovery.firstValue()?.boolValue
- return showHidden == true && showOfflineLast == false && currentTheme == .dark && autoDiscovery == false
+ let soundSyncBeta = await preferences.soundSyncBetaEnabled.firstValue()?.boolValue
+ return showHidden == true
+ && showOfflineLast == false
+ && currentTheme == .dark
+ && autoDiscovery == false
+ && soundSyncBeta == true
}
#expect(viewModel.visibleDevices.map(\.device.displayName) == ["Hidden Device"])
diff --git a/iosApp/wledTests/SettingsSnapshotTests.swift b/iosApp/wledTests/SettingsSnapshotTests.swift
index c96cfe81..77fe71f2 100644
--- a/iosApp/wledTests/SettingsSnapshotTests.swift
+++ b/iosApp/wledTests/SettingsSnapshotTests.swift
@@ -32,6 +32,7 @@ struct SettingsSnapshotTests {
showOfflineDevicesLast: .constant(true),
theme: .constant(.auto),
autoDiscovery: .constant(true),
+ soundSyncBetaEnabled: .constant(false),
onShowChangelog: {},
appVersion: "7.2.0",
buildNumber: "100"
diff --git a/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListDarkSnapshot.1.png b/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListDarkSnapshot.1.png
index a5b17da0..ad473f60 100644
Binary files a/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListDarkSnapshot.1.png and b/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListDarkSnapshot.1.png differ
diff --git a/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListLightSnapshot.1.png b/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListLightSnapshot.1.png
index 90209e31..745313bf 100644
Binary files a/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListLightSnapshot.1.png and b/iosApp/wledTests/__Snapshots__/DeviceListViewSnapshotTests/testDeviceListLightSnapshot.1.png differ
diff --git a/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsDarkSnapshot.1.png b/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsDarkSnapshot.1.png
index b45f74f7..70d4ea57 100644
Binary files a/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsDarkSnapshot.1.png and b/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsDarkSnapshot.1.png differ
diff --git a/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsLightSnapshot.1.png b/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsLightSnapshot.1.png
index a9d1d32b..7de82edb 100644
Binary files a/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsLightSnapshot.1.png and b/iosApp/wledTests/__Snapshots__/SettingsSnapshotTests/testSettingsLightSnapshot.1.png differ
diff --git a/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferences.kt b/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferences.kt
index c60771e7..2569091c 100644
--- a/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferences.kt
+++ b/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferences.kt
@@ -18,4 +18,5 @@ data class UserPreferences(
val lastChangelogVersionSeen: String = "",
val soundSyncGain: Float = 1.0f,
val soundSyncAgcEnabled: Boolean = false,
+ val soundSyncBetaEnabled: Boolean = false,
)
diff --git a/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferencesRepository.kt b/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferencesRepository.kt
index 7bce807e..a6e83701 100644
--- a/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferencesRepository.kt
+++ b/shared/src/commonMain/kotlin/ca/cgagnier/wlednativeandroid/repository/UserPreferencesRepository.kt
@@ -26,6 +26,9 @@ class UserPreferencesRepository(private val dataStore: DataStore = dataStore.data.map {
it.soundSyncAgcEnabled
}.distinctUntilChanged()
+ val soundSyncBetaEnabled: Flow = dataStore.data.map {
+ it.soundSyncBetaEnabled
+ }.distinctUntilChanged()
suspend fun updateThemeMode(themeSettings: ThemeSettings) {
logger.d { "updateThemeMode" }
@@ -106,4 +109,14 @@ class UserPreferencesRepository(private val dataStore: DataStore
+ repo.updateSoundSyncBetaEnabled(true)
+ assertTrue(repo.soundSyncBetaEnabled.first())
+
+ repo.updateSoundSyncBetaEnabled(false)
+ assertFalse(repo.soundSyncBetaEnabled.first())
+ }
+
@Test
fun updatesToOtherPreferences_doNotEmitDuplicateValuesOnUnrelatedFlows() = withRepository { repo ->
val emissions = mutableListOf()