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()