feat(infrared): expand open source remote library
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@ interface InfraredDao {
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
||||
ORDER BY createdAtEpochMillis DESC
|
||||
ORDER BY createdAtEpochMillis ASC, id ASC
|
||||
""",
|
||||
)
|
||||
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
|
||||
|
||||
+7
@@ -192,6 +192,13 @@ object InfraredFeatureModule {
|
||||
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
|
||||
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTestPresetInfraredCode(
|
||||
gateway: InfraredGateway,
|
||||
): com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode =
|
||||
com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode(gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeleteVirtualRemote(
|
||||
|
||||
+626
-254
@@ -105,6 +105,7 @@ fun InfraredHubRoute(
|
||||
onOpenRemote = onOpenRemote,
|
||||
onDeleteRemote = viewModel::deleteRemote,
|
||||
onCreateRemote = viewModel::createRemote,
|
||||
onTestPreset = viewModel::testPreset,
|
||||
onSaveDeviceName = viewModel::saveDeviceName,
|
||||
onMoveRemote = viewModel::moveRemote,
|
||||
onUpdateRemote = viewModel::updateRemote,
|
||||
@@ -134,6 +135,7 @@ internal fun InfraredHubScreen(
|
||||
onOpenRemote: (String) -> Unit,
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
||||
onTestPreset: (String, (Boolean) -> Unit) -> Unit = { _, result -> result(false) },
|
||||
onSaveDeviceName: (String) -> Unit = {},
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
|
||||
@@ -156,6 +158,7 @@ internal fun InfraredHubScreen(
|
||||
val resources = LocalResources.current
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showManagement by remember { mutableStateOf(false) }
|
||||
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
@@ -303,7 +306,7 @@ internal fun InfraredHubScreen(
|
||||
onDeleteRemote = onDeleteRemote,
|
||||
onSaveDeviceName = onSaveDeviceName,
|
||||
onMoveRemote = onMoveRemote,
|
||||
onUpdateRemote = onUpdateRemote,
|
||||
onEditRemote = { editingRemote = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -311,7 +314,9 @@ internal fun InfraredHubScreen(
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateVirtualRemoteDialog(
|
||||
CreateVirtualRemoteFlow(
|
||||
slotQuota = state.slotQuota,
|
||||
onTestPreset = onTestPreset,
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
|
||||
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
|
||||
@@ -320,6 +325,21 @@ internal fun InfraredHubScreen(
|
||||
)
|
||||
}
|
||||
|
||||
editingRemote?.let { remote ->
|
||||
EditRemoteScreen(
|
||||
remote = remote,
|
||||
onDismiss = { editingRemote = null },
|
||||
onSave = { name, layout, mode ->
|
||||
editingRemote = null
|
||||
onUpdateRemote(remote.id, name, layout, mode)
|
||||
},
|
||||
onDelete = {
|
||||
editingRemote = null
|
||||
onDeleteRemote(remote.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
|
||||
@@ -369,11 +389,10 @@ private fun InfraredManagementScreen(
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onSaveDeviceName: (String) -> Unit,
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
|
||||
onEditRemote: (VirtualRemote) -> Unit,
|
||||
) {
|
||||
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
|
||||
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
|
||||
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
@@ -438,7 +457,7 @@ private fun InfraredManagementScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
|
||||
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { onEditRemote(remote) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,24 +485,11 @@ private fun InfraredManagementScreen(
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
editingRemote?.let { remote ->
|
||||
EditRemoteDialog(
|
||||
remote = remote,
|
||||
onDismiss = { editingRemote = null },
|
||||
onSave = { name, layout, mode ->
|
||||
editingRemote = null
|
||||
onUpdateRemote(remote.id, name, layout, mode)
|
||||
},
|
||||
onDelete = {
|
||||
editingRemote = null
|
||||
onDeleteRemote(remote.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditRemoteDialog(
|
||||
private fun EditRemoteScreen(
|
||||
remote: VirtualRemote,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
|
||||
@@ -491,39 +497,153 @@ private fun EditRemoteDialog(
|
||||
) {
|
||||
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
|
||||
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
|
||||
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.infrared_edit_remote)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
|
||||
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
|
||||
PanelLayoutStyle.entries.forEach { option ->
|
||||
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = layout == option, onClick = { layout = option })
|
||||
Text(layoutLabel(option))
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
|
||||
TransmissionMode.entries.forEach { option ->
|
||||
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = mode == option, onClick = { mode = option })
|
||||
Text(transmissionLabel(option))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
|
||||
var showDeleteConfirmation by remember(remote.id) { mutableStateOf(false) }
|
||||
val isCustom = remote.type == RemoteDeviceType.CUSTOM
|
||||
val hasChanges = name.trim() != remote.displayName || layout != remote.layoutStyle
|
||||
|
||||
ABoxBackLayer(onBack = onDismiss) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.infrared_edit_remote))
|
||||
Text(
|
||||
remote.displayName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
|
||||
navigationIcon = {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.BACK,
|
||||
contentDescription = stringResource(R.string.infrared_back),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 4.dp) {
|
||||
Button(
|
||||
onClick = { onSave(name.trim(), layout, remote.transmissionMode) },
|
||||
enabled = name.isNotBlank() && hasChanges,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp).height(48.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_device_save_changes))
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_edit_identity_title),
|
||||
stringResource(R.string.infrared_edit_identity_desc),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.infrared_remote_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
RemoteConfigurationSummary(remote)
|
||||
|
||||
if (isCustom) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_edit_layout_title),
|
||||
stringResource(R.string.infrared_edit_layout_desc),
|
||||
)
|
||||
LearningLayoutOption.entries.forEach { option ->
|
||||
WizardChoice(
|
||||
title = stringResource(option.titleRes),
|
||||
description = stringResource(option.descriptionRes),
|
||||
icon = option.icon,
|
||||
selected = layout == option.layout,
|
||||
onClick = { layout = option.layout },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_library_locked),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_delete_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_delete_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmation = true },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmation = false },
|
||||
title = { Text(stringResource(R.string.infrared_edit_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.infrared_edit_delete_confirm_message, remote.displayName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDelete) {
|
||||
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirmation = false }) {
|
||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
|
||||
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
|
||||
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
|
||||
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
|
||||
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
|
||||
}
|
||||
|
||||
@@ -541,6 +661,19 @@ private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
|
||||
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun remoteTypeLabel(type: RemoteDeviceType): String = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_hub_device_type_projector)
|
||||
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_hub_device_type_audio)
|
||||
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_hub_device_type_set_top_box)
|
||||
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_hub_device_type_streaming)
|
||||
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_hub_device_type_light)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VirtualRemoteCard(
|
||||
remote: VirtualRemote,
|
||||
@@ -569,6 +702,11 @@ private fun VirtualRemoteCard(
|
||||
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
|
||||
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
|
||||
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
|
||||
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
|
||||
}
|
||||
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
|
||||
@@ -672,239 +810,473 @@ private fun InfraredHardwareSummary(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreateVirtualRemoteDialog(
|
||||
private fun CreateVirtualRemoteFlow(
|
||||
slotQuota: SlotQuotaSummary,
|
||||
onTestPreset: (String, (Boolean) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
|
||||
) {
|
||||
var step by remember { mutableStateOf(CreateRemoteStep.SOURCE) }
|
||||
var source by remember { mutableStateOf<CreateRemoteSource?>(null) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
|
||||
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
|
||||
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
|
||||
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
|
||||
var testState by remember { mutableStateOf(PresetTestState.NOT_TESTED) }
|
||||
val isLearning = source == CreateRemoteSource.LEARNING
|
||||
val effectiveType = if (isLearning) RemoteDeviceType.CUSTOM else selectedType
|
||||
val defaultName = if (isLearning) {
|
||||
stringResource(R.string.infrared_create_default_custom_name)
|
||||
} else {
|
||||
selectedBrand.displayName
|
||||
}
|
||||
val finalName = name.trim().ifBlank { defaultName }
|
||||
val canCreate = !isLearning || !slotQuota.isStaticFull
|
||||
val requiresPresetTest = !isLearning && selectedType != RemoteDeviceType.AIR_CONDITIONER
|
||||
val presetConfirmed = !requiresPresetTest || testState == PresetTestState.CONFIRMED
|
||||
|
||||
var typeDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var brandDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var layoutDropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// 1. 设备类型选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = typeDropdownExpanded,
|
||||
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedType) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = typeDropdownExpanded,
|
||||
onDismissRequest = { typeDropdownExpanded = false },
|
||||
) {
|
||||
RemoteDeviceType.entries.forEach { type ->
|
||||
val label = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
ABoxBackLayer(onBack = {
|
||||
step = when (step) {
|
||||
CreateRemoteStep.SOURCE -> return@ABoxBackLayer onDismiss()
|
||||
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
|
||||
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
selectedType = type
|
||||
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
|
||||
if (matched != null) selectedBrand = matched
|
||||
if (type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
|
||||
} else if (type == RemoteDeviceType.CUSTOM) {
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS
|
||||
}
|
||||
typeDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 遥控器名称输入
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("遥控器名称(如:电视、我的遥控器)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
|
||||
// 3. 自定义遥控器时:选择初始布局模板
|
||||
if (selectedType == RemoteDeviceType.CUSTOM) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedCustomLayout) {
|
||||
PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
|
||||
PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
|
||||
PanelLayoutStyle.AC_COMPACT -> "空调布局"
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("选择初始布局模板") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onDismissRequest = { layoutDropdownExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("电视导航布局(内置方向盘、音量、频道)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("媒体播放器布局(播放、快进、切歌)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("自由网格布局(可自由增删按键)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.GRID
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
}) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.infrared_create_title))
|
||||
Text(
|
||||
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
stringResource(R.string.infrared_create_step, step.ordinal + 1, 3),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (filteredPresets.isNotEmpty()) {
|
||||
// 码库预设设备:选择品牌型号
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = brandDropdownExpanded,
|
||||
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedBrand.displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = brandDropdownExpanded,
|
||||
onDismissRequest = { brandDropdownExpanded = false },
|
||||
) {
|
||||
filteredPresets.forEach { preset ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(preset.displayName) },
|
||||
},
|
||||
navigationIcon = {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.BACK,
|
||||
contentDescription = stringResource(R.string.infrared_back),
|
||||
onClick = {
|
||||
selectedBrand = preset
|
||||
brandDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
|
||||
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
|
||||
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
)
|
||||
Column {
|
||||
Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
|
||||
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
)
|
||||
Column {
|
||||
Text("动态写码模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
step = when (step) {
|
||||
CreateRemoteStep.SOURCE -> return@ABoxIconButton onDismiss()
|
||||
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
|
||||
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 4.dp) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (step != CreateRemoteStep.SOURCE) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
step = if (step == CreateRemoteStep.DETAILS) CreateRemoteStep.OPTIONS else CreateRemoteStep.SOURCE
|
||||
},
|
||||
modifier = Modifier.height(48.dp),
|
||||
) { Text(stringResource(R.string.infrared_back)) }
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
|
||||
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
|
||||
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
|
||||
when (step) {
|
||||
CreateRemoteStep.SOURCE -> step = CreateRemoteStep.OPTIONS
|
||||
CreateRemoteStep.OPTIONS -> step = CreateRemoteStep.DETAILS
|
||||
CreateRemoteStep.DETAILS -> {
|
||||
onConfirm(
|
||||
finalName,
|
||||
effectiveType,
|
||||
if (isLearning) "custom_blank" else selectedBrand.id,
|
||||
if (isLearning) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
|
||||
if (isLearning) selectedCustomLayout else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("create_remote_confirm_button"),
|
||||
enabled = when (step) {
|
||||
CreateRemoteStep.SOURCE -> source != null
|
||||
CreateRemoteStep.OPTIONS -> source != null
|
||||
CreateRemoteStep.DETAILS -> canCreate && finalName.isNotBlank() && presetConfirmed
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(48.dp).testTag("create_remote_confirm_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
||||
Text(
|
||||
if (step == CreateRemoteStep.DETAILS) stringResource(R.string.infrared_create_action)
|
||||
else stringResource(R.string.infrared_create_continue),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (step) {
|
||||
CreateRemoteStep.SOURCE -> {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_source_title),
|
||||
stringResource(R.string.infrared_create_source_desc),
|
||||
)
|
||||
WizardChoice(
|
||||
title = stringResource(R.string.infrared_create_library_title),
|
||||
description = stringResource(R.string.infrared_create_library_desc),
|
||||
icon = ABoxIconKey.ADD,
|
||||
selected = source == CreateRemoteSource.LIBRARY,
|
||||
onClick = {
|
||||
source = CreateRemoteSource.LIBRARY
|
||||
selectedType = RemoteDeviceType.TV
|
||||
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == RemoteDeviceType.TV }
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
WizardChoice(
|
||||
title = stringResource(R.string.infrared_create_learning_title),
|
||||
description = stringResource(R.string.infrared_create_learning_desc),
|
||||
icon = ABoxIconKey.INFRARED,
|
||||
selected = source == CreateRemoteSource.LEARNING,
|
||||
onClick = { source = CreateRemoteSource.LEARNING },
|
||||
)
|
||||
}
|
||||
CreateRemoteStep.OPTIONS -> if (isLearning) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_layout_title),
|
||||
stringResource(R.string.infrared_create_layout_desc),
|
||||
)
|
||||
LearningLayoutOption.entries.forEach { option ->
|
||||
WizardChoice(
|
||||
title = stringResource(option.titleRes),
|
||||
description = stringResource(option.descriptionRes),
|
||||
icon = option.icon,
|
||||
selected = selectedCustomLayout == option.layout,
|
||||
onClick = { selectedCustomLayout = option.layout },
|
||||
)
|
||||
}
|
||||
SlotAvailabilityCard(slotQuota)
|
||||
} else {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_device_title),
|
||||
stringResource(R.string.infrared_create_device_desc),
|
||||
)
|
||||
PresetCodeLibrary.availablePresets
|
||||
.map { it.deviceType }
|
||||
.filter { it != RemoteDeviceType.CUSTOM }
|
||||
.distinct()
|
||||
.forEach { type ->
|
||||
WizardChoice(
|
||||
title = remoteTypeLabel(type),
|
||||
description = remoteTypeDescription(type),
|
||||
icon = remoteIcon(type),
|
||||
selected = selectedType == type,
|
||||
onClick = {
|
||||
selectedType = type
|
||||
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == type }
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.infrared_create_preset_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }.forEach { preset ->
|
||||
WizardChoice(
|
||||
title = preset.displayName,
|
||||
description = stringResource(R.string.infrared_create_preset_desc),
|
||||
icon = ABoxIconKey.ADD,
|
||||
selected = selectedBrand.id == preset.id,
|
||||
onClick = {
|
||||
selectedBrand = preset
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
CreateRemoteStep.DETAILS -> {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_name_title),
|
||||
stringResource(R.string.infrared_create_name_desc),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.infrared_remote_name)) },
|
||||
placeholder = { Text(defaultName) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
CreateRemoteSummary(
|
||||
source = source,
|
||||
type = effectiveType,
|
||||
presetName = selectedBrand.displayName,
|
||||
layout = selectedCustomLayout,
|
||||
finalName = finalName,
|
||||
)
|
||||
if (requiresPresetTest) {
|
||||
PresetTestCard(
|
||||
state = testState,
|
||||
onTest = {
|
||||
testState = PresetTestState.TESTING
|
||||
onTestPreset(selectedBrand.id) { successful ->
|
||||
testState = if (successful) PresetTestState.WAITING_CONFIRMATION else PresetTestState.FAILED
|
||||
}
|
||||
},
|
||||
onConfirmed = { testState = PresetTestState.CONFIRMED },
|
||||
onFailed = { testState = PresetTestState.NOT_TESTED },
|
||||
)
|
||||
}
|
||||
if (isLearning) SlotAvailabilityCard(slotQuota)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CreateRemoteStep { SOURCE, OPTIONS, DETAILS }
|
||||
private enum class CreateRemoteSource { LIBRARY, LEARNING }
|
||||
private enum class PresetTestState { NOT_TESTED, TESTING, WAITING_CONFIRMATION, CONFIRMED, FAILED }
|
||||
|
||||
private enum class LearningLayoutOption(
|
||||
val layout: PanelLayoutStyle,
|
||||
val titleRes: Int,
|
||||
val descriptionRes: Int,
|
||||
val icon: ABoxIconKey,
|
||||
) {
|
||||
TV(PanelLayoutStyle.TV_NAV, R.string.infrared_layout_tv, R.string.infrared_layout_tv_desc, ABoxIconKey.DEVICES),
|
||||
MEDIA(PanelLayoutStyle.MEDIA_PLAYER, R.string.infrared_layout_media, R.string.infrared_layout_media_desc, ABoxIconKey.ACTIVITY),
|
||||
GRID(PanelLayoutStyle.GRID, R.string.infrared_layout_grid, R.string.infrared_layout_grid_desc, ABoxIconKey.DEVICES),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardHeading(title: String, description: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardChoice(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ABoxIconKey,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
ABoxIcon(
|
||||
key = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.padding(10.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlotAvailabilityCard(quota: SlotQuotaSummary) {
|
||||
val available = quota.remainingStaticCount
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (available > 0) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_create_slots_available, available),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
if (available > 0) stringResource(R.string.infrared_create_slots_desc)
|
||||
else stringResource(R.string.infrared_create_slots_full),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetTestCard(
|
||||
state: PresetTestState,
|
||||
onTest: () -> Unit,
|
||||
onConfirmed: () -> Unit,
|
||||
onFailed: () -> Unit,
|
||||
) {
|
||||
val successful = state == PresetTestState.CONFIRMED
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = when (state) {
|
||||
PresetTestState.CONFIRMED -> MaterialTheme.colorScheme.secondaryContainer
|
||||
PresetTestState.FAILED -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_preset_test_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
when (state) {
|
||||
PresetTestState.NOT_TESTED -> stringResource(R.string.infrared_preset_test_desc)
|
||||
PresetTestState.TESTING -> stringResource(R.string.infrared_preset_test_sending)
|
||||
PresetTestState.WAITING_CONFIRMATION -> stringResource(R.string.infrared_preset_test_confirm_prompt)
|
||||
PresetTestState.CONFIRMED -> stringResource(R.string.infrared_preset_test_confirmed)
|
||||
PresetTestState.FAILED -> stringResource(R.string.infrared_preset_test_failed)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when {
|
||||
successful -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
state == PresetTestState.FAILED -> MaterialTheme.colorScheme.onErrorContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
when (state) {
|
||||
PresetTestState.NOT_TESTED, PresetTestState.FAILED -> Button(
|
||||
onClick = onTest,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.infrared_preset_test_action)) }
|
||||
PresetTestState.TESTING -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.infrared_preset_test_sending), modifier = Modifier.padding(start = 10.dp))
|
||||
}
|
||||
PresetTestState.WAITING_CONFIRMATION -> Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
TextButton(onClick = onFailed, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.infrared_preset_test_no_response))
|
||||
}
|
||||
Button(onClick = onConfirmed, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.infrared_preset_test_responded))
|
||||
}
|
||||
}
|
||||
PresetTestState.CONFIRMED -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateRemoteSummary(
|
||||
source: CreateRemoteSource?,
|
||||
type: RemoteDeviceType,
|
||||
presetName: String,
|
||||
layout: PanelLayoutStyle,
|
||||
finalName: String,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.infrared_create_summary_title), fontWeight = FontWeight.Bold)
|
||||
SummaryRow(stringResource(R.string.infrared_remote_name), finalName)
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_create_source_label),
|
||||
if (source == CreateRemoteSource.LEARNING) stringResource(R.string.infrared_create_learning_title)
|
||||
else stringResource(R.string.infrared_create_library_title),
|
||||
)
|
||||
if (source == CreateRemoteSource.LEARNING) {
|
||||
SummaryRow(stringResource(R.string.infrared_layout), layoutLabel(layout))
|
||||
} else {
|
||||
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(type))
|
||||
SummaryRow(stringResource(R.string.infrared_hub_brand_label), presetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun remoteTypeDescription(type: RemoteDeviceType): String = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_create_device_tv_desc)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_create_device_ac_desc)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_create_device_fan_desc)
|
||||
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_create_device_projector_desc)
|
||||
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_create_device_audio_desc)
|
||||
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_create_device_set_top_box_desc)
|
||||
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_create_device_streaming_desc)
|
||||
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_create_device_light_desc)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_create_learning_desc)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteConfigurationSummary(remote: VirtualRemote) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.infrared_edit_configuration_title), fontWeight = FontWeight.Bold)
|
||||
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(remote.type))
|
||||
if (remote.type == RemoteDeviceType.CUSTOM) {
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_create_source_label),
|
||||
stringResource(R.string.infrared_create_learning_title),
|
||||
)
|
||||
} else {
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_hub_brand_label),
|
||||
remote.brandModel ?: stringResource(R.string.infrared_custom),
|
||||
)
|
||||
}
|
||||
SummaryRow(stringResource(R.string.infrared_transmission_mode), transmissionLabel(remote.transmissionMode))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -65,6 +65,7 @@ class InfraredHubViewModel @Inject constructor(
|
||||
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
||||
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
||||
private val activityWriter: ActivityEventWriter,
|
||||
private val testPresetInfraredCode: com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
@@ -244,6 +245,15 @@ class InfraredHubViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun testPreset(presetId: String, onResult: (Boolean) -> Unit) {
|
||||
val currentProfile = profileId ?: return onResult(false)
|
||||
val tester = testPresetInfraredCode ?: return onResult(false)
|
||||
val currentDevice = state.value.savedDeviceName
|
||||
viewModelScope.launch {
|
||||
onResult(tester(currentProfile, currentDevice, presetId) is DomainResult.Success)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRemote(remoteId: VirtualRemoteId) {
|
||||
selectedRemoteIdFlow.value = remoteId
|
||||
localAcStateFlow.value = null
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
|
||||
<string name="infrared_hub_device_type_tv">电视机 / 电视盒子</string>
|
||||
<string name="infrared_hub_device_type_ac">空调设备</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
|
||||
<string name="infrared_hub_device_type_fan">电风扇 / 空气净化器</string>
|
||||
<string name="infrared_hub_device_type_projector">投影仪</string>
|
||||
<string name="infrared_hub_device_type_audio">音响 / 功放</string>
|
||||
<string name="infrared_hub_device_type_set_top_box">机顶盒</string>
|
||||
<string name="infrared_hub_device_type_streaming">流媒体设备</string>
|
||||
<string name="infrared_hub_device_type_light">灯具</string>
|
||||
<string name="infrared_hub_device_type_custom">自定义面板</string>
|
||||
<string name="infrared_set_temperature">设定温度</string>
|
||||
<string name="infrared_running_mode">运行模式</string>
|
||||
@@ -189,4 +194,55 @@
|
||||
<string name="infrared_layout">面板布局</string>
|
||||
<string name="infrared_transmission_mode">传输模式</string>
|
||||
<string name="infrared_delete_remote">删除遥控器</string>
|
||||
<string name="infrared_create_title">添加遥控器</string>
|
||||
<string name="infrared_create_step">第 %1$d 步,共 %2$d 步</string>
|
||||
<string name="infrared_create_continue">继续</string>
|
||||
<string name="infrared_create_action">创建遥控器</string>
|
||||
<string name="infrared_create_source_title">你想如何添加遥控器?</string>
|
||||
<string name="infrared_create_source_desc">请选择红外指令的来源。两种方式会进入不同的设置流程。</string>
|
||||
<string name="infrared_create_library_title">从红外码库添加</string>
|
||||
<string name="infrared_create_library_desc">选择设备类型和预设方案,下载后按键可直接使用,无需学习。</string>
|
||||
<string name="infrared_create_learning_title">通过学习创建</string>
|
||||
<string name="infrared_create_learning_desc">创建自定义面板,再使用实体遥控器逐个录制红外指令。</string>
|
||||
<string name="infrared_create_device_title">选择设备与预设方案</string>
|
||||
<string name="infrared_create_device_desc">先选择要控制的设备类型,再选择对应的红外码库方案。</string>
|
||||
<string name="infrared_create_device_tv_desc">包含导航、音量、频道、信号源等常用电视控制</string>
|
||||
<string name="infrared_create_device_ac_desc">包含温度、模式、风速、扫风和电源等状态控制</string>
|
||||
<string name="infrared_create_device_fan_desc">包含电源、风速、摇头、定时和运行模式控制</string>
|
||||
<string name="infrared_create_device_projector_desc">包含电源、信号源、菜单、导航、画面模式和投影控制</string>
|
||||
<string name="infrared_create_device_audio_desc">包含电源、音量、静音、输入源、播放和音效控制</string>
|
||||
<string name="infrared_create_device_set_top_box_desc">包含电源、频道、导航、节目单、菜单和数字按键</string>
|
||||
<string name="infrared_create_device_streaming_desc">包含电源、导航、播放、主页、返回和媒体控制</string>
|
||||
<string name="infrared_create_device_light_desc">包含电源、亮度、颜色、场景、定时和灯效控制</string>
|
||||
<string name="infrared_create_preset_title">红外码库方案</string>
|
||||
<string name="infrared_create_preset_desc">为所选设备下载这套红外指令</string>
|
||||
<string name="infrared_create_layout_title">选择学习面板</string>
|
||||
<string name="infrared_create_layout_desc">面板布局决定开始学习前会预置哪些按键。</string>
|
||||
<string name="infrared_create_name_title">命名并确认</string>
|
||||
<string name="infrared_create_name_desc">设置一个容易识别的名称,并在创建前检查选择结果。</string>
|
||||
<string name="infrared_create_default_custom_name">自定义遥控器</string>
|
||||
<string name="infrared_create_slots_available">剩余 %1$d 个静态槽位</string>
|
||||
<string name="infrared_create_slots_desc">学习遥控器使用静态硬件槽位,录制指令时会逐个分配。</string>
|
||||
<string name="infrared_create_slots_full">没有可用静态槽位。请先删除不再使用的已学习指令。</string>
|
||||
<string name="infrared_create_summary_title">设置摘要</string>
|
||||
<string name="infrared_create_source_label">添加方式</string>
|
||||
<string name="infrared_edit_identity_title">遥控器信息</string>
|
||||
<string name="infrared_edit_identity_desc">修改遥控器列表和自动化指令中显示的名称。</string>
|
||||
<string name="infrared_edit_configuration_title">当前配置</string>
|
||||
<string name="infrared_edit_layout_title">面板布局</string>
|
||||
<string name="infrared_edit_layout_desc">学习创建的遥控器可以切换支持的按键布局,但不会变成码库遥控器。</string>
|
||||
<string name="infrared_edit_library_locked">码库遥控器会保留创建时的设备类型、预设方案、布局和传输方式。如需其他方案,请重新添加一个遥控器。</string>
|
||||
<string name="infrared_edit_delete_title">移除遥控器</string>
|
||||
<string name="infrared_edit_delete_desc">从 App 中删除该遥控器及其保存的按键绑定。</string>
|
||||
<string name="infrared_edit_delete_confirm_title">删除这个遥控器?</string>
|
||||
<string name="infrared_edit_delete_confirm_message">将删除“%1$s”及其按键配置。</string>
|
||||
<string name="infrared_preset_test_title">测试这套码库</string>
|
||||
<string name="infrared_preset_test_desc">保存前先发送电源指令,确认设备是否响应。测试可能会打开或关闭设备。</string>
|
||||
<string name="infrared_preset_test_action">测试电源指令</string>
|
||||
<string name="infrared_preset_test_sending">正在发送测试指令…</string>
|
||||
<string name="infrared_preset_test_confirm_prompt">指令已发送,设备是否有响应?</string>
|
||||
<string name="infrared_preset_test_responded">有响应</string>
|
||||
<string name="infrared_preset_test_no_response">无响应</string>
|
||||
<string name="infrared_preset_test_confirmed">码库已验证,可以创建遥控器。</string>
|
||||
<string name="infrared_preset_test_failed">测试指令发送失败,请检查红外控制器后重试。</string>
|
||||
</resources>
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
|
||||
<string name="infrared_hub_device_type_tv">Television (TV)</string>
|
||||
<string name="infrared_hub_device_type_ac">Air Conditioner (AC)</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_fan">Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_projector">Projector</string>
|
||||
<string name="infrared_hub_device_type_audio">Audio / Receiver</string>
|
||||
<string name="infrared_hub_device_type_set_top_box">Set-top Box</string>
|
||||
<string name="infrared_hub_device_type_streaming">Streaming Device</string>
|
||||
<string name="infrared_hub_device_type_light">Lighting</string>
|
||||
<string name="infrared_hub_device_type_custom">Custom Panel</string>
|
||||
<string name="infrared_set_temperature">Set temperature</string>
|
||||
<string name="infrared_running_mode">Operating mode</string>
|
||||
@@ -189,4 +194,55 @@
|
||||
<string name="infrared_layout">Panel layout</string>
|
||||
<string name="infrared_transmission_mode">Transmission mode</string>
|
||||
<string name="infrared_delete_remote">Delete remote</string>
|
||||
<string name="infrared_create_title">Add remote</string>
|
||||
<string name="infrared_create_step">Step %1$d of %2$d</string>
|
||||
<string name="infrared_create_continue">Continue</string>
|
||||
<string name="infrared_create_action">Create remote</string>
|
||||
<string name="infrared_create_source_title">How do you want to add the remote?</string>
|
||||
<string name="infrared_create_source_desc">Choose where the infrared commands come from. The two methods use different setup flows.</string>
|
||||
<string name="infrared_create_library_title">Add from infrared library</string>
|
||||
<string name="infrared_create_library_desc">Choose a device type and preset. Commands are downloaded and ready to use without learning.</string>
|
||||
<string name="infrared_create_learning_title">Create through learning</string>
|
||||
<string name="infrared_create_learning_desc">Build a custom panel, then record each command from a physical remote in Learn mode.</string>
|
||||
<string name="infrared_create_device_title">Choose the appliance and preset</string>
|
||||
<string name="infrared_create_device_desc">Select the appliance type first, then choose the matching preset library.</string>
|
||||
<string name="infrared_create_device_tv_desc">Navigation, volume, channel, source, and common TV controls</string>
|
||||
<string name="infrared_create_device_ac_desc">State-based temperature, mode, fan, swing, and power controls</string>
|
||||
<string name="infrared_create_device_fan_desc">Power, speed, swing, timer, and operating mode controls</string>
|
||||
<string name="infrared_create_device_projector_desc">Power, input, menu, navigation, picture mode, and projection controls</string>
|
||||
<string name="infrared_create_device_audio_desc">Power, volume, mute, source, playback, and sound controls</string>
|
||||
<string name="infrared_create_device_set_top_box_desc">Power, channel, navigation, guide, menu, and numeric controls</string>
|
||||
<string name="infrared_create_device_streaming_desc">Power, navigation, playback, home, back, and media controls</string>
|
||||
<string name="infrared_create_device_light_desc">Power, brightness, color, scene, timer, and effect controls</string>
|
||||
<string name="infrared_create_preset_title">Preset library</string>
|
||||
<string name="infrared_create_preset_desc">Download this command set for the selected appliance</string>
|
||||
<string name="infrared_create_layout_title">Choose the learning panel</string>
|
||||
<string name="infrared_create_layout_desc">The layout defines which buttons are created before you start recording commands.</string>
|
||||
<string name="infrared_create_name_title">Name and confirm</string>
|
||||
<string name="infrared_create_name_desc">Give the remote a recognizable name and review the setup before creating it.</string>
|
||||
<string name="infrared_create_default_custom_name">Custom remote</string>
|
||||
<string name="infrared_create_slots_available">%1$d static slots available</string>
|
||||
<string name="infrared_create_slots_desc">Learning remotes use static hardware slots. Slots are assigned as commands are recorded.</string>
|
||||
<string name="infrared_create_slots_full">No static slots are available. Remove unused learned commands before creating another learning remote.</string>
|
||||
<string name="infrared_create_summary_title">Setup summary</string>
|
||||
<string name="infrared_create_source_label">Add method</string>
|
||||
<string name="infrared_edit_identity_title">Remote identity</string>
|
||||
<string name="infrared_edit_identity_desc">Change the name shown in the remote list and automation actions.</string>
|
||||
<string name="infrared_edit_configuration_title">Current configuration</string>
|
||||
<string name="infrared_edit_layout_title">Panel layout</string>
|
||||
<string name="infrared_edit_layout_desc">Learning remotes can switch between supported button layouts without becoming library remotes.</string>
|
||||
<string name="infrared_edit_library_locked">Library remotes keep the appliance type, preset, layout, and transmission behavior used when they were created. Create another remote to use a different preset.</string>
|
||||
<string name="infrared_edit_delete_title">Remove remote</string>
|
||||
<string name="infrared_edit_delete_desc">Removes the remote and its saved button bindings from this app.</string>
|
||||
<string name="infrared_edit_delete_confirm_title">Remove this remote?</string>
|
||||
<string name="infrared_edit_delete_confirm_message">“%1$s” and its button configuration will be removed.</string>
|
||||
<string name="infrared_preset_test_title">Test this preset</string>
|
||||
<string name="infrared_preset_test_desc">Before saving, send the preset power command and check whether the appliance responds. The appliance may turn on or off.</string>
|
||||
<string name="infrared_preset_test_action">Test power command</string>
|
||||
<string name="infrared_preset_test_sending">Sending the test command…</string>
|
||||
<string name="infrared_preset_test_confirm_prompt">The command was sent. Did the appliance respond?</string>
|
||||
<string name="infrared_preset_test_responded">Yes, it responded</string>
|
||||
<string name="infrared_preset_test_no_response">No response</string>
|
||||
<string name="infrared_preset_test_confirmed">Preset verified. You can create the remote.</string>
|
||||
<string name="infrared_preset_test_failed">The test command could not be sent. Check the infrared controller and try again.</string>
|
||||
</resources>
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
# Flipper-IRDB attribution
|
||||
|
||||
The built-in open-source infrared presets in this project are derived from selected files in:
|
||||
|
||||
- Project: Flipper-IRDB
|
||||
- Upstream: https://github.com/Lucaslhm/Flipper-IRDB
|
||||
- License: CC0 1.0 Universal
|
||||
- Imported signal formats: parsed NEC and NECext only
|
||||
|
||||
Raw pulse data and protocols other than NEC/NECext are intentionally excluded because the ABox gateway's `irCode` encoding contract is not publicly specified for those formats.
|
||||
|
||||
Each imported preset retains its upstream relative `sourcePath` in `OpenSourceIrCatalog.kt` for traceability. The source data was converted to the four-byte hexadecimal representation already used by the ABox application:
|
||||
|
||||
- NEC: address, inverted address, command, inverted command
|
||||
- NECext: extended address byte 1, extended address byte 2, command byte 1, command byte 2
|
||||
|
||||
Flipper-IRDB is dedicated to the public domain under CC0 1.0. The full license text is available at:
|
||||
|
||||
https://creativecommons.org/publicdomain/zero/1.0/legalcode
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.flagship.abox.manager.domain.infrared
|
||||
|
||||
internal data class OpenSourceIrPreset(
|
||||
val option: PresetBrandOption,
|
||||
val buttons: List<PresetCodeLibrary.PresetButtonTemplate>,
|
||||
)
|
||||
|
||||
internal object OpenSourceIrCatalog {
|
||||
val presets = listOf(
|
||||
preset("firetv_omni_4k", "Amazon Fire TV Omni 4K", RemoteDeviceType.TV, "TVs/Amazon/FireTV_Omni_Series_4K.ir",
|
||||
"POWER" to "电源" to "0x027D46B9", "HOME" to "主页" to "0x027D9F60", "MENU" to "菜单" to "0x027D45BA",
|
||||
"BACK" to "返回" to "0x027D0DF2", "VOL_UP" to "音量 +" to "0x027D0CF3", "VOL_DOWN" to "音量 -" to "0x027D19E6",
|
||||
"MUTE" to "静音" to "0x027D4CB3", "UP" to "上" to "0x027D48B7", "DOWN" to "下" to "0x027D4DB2",
|
||||
"LEFT" to "左" to "0x027D4EB1", "RIGHT" to "右" to "0x027D49B6", "OK" to "确定" to "0x027D4AB5"),
|
||||
preset("akai_ate_22y604w", "Akai ATE-22Y604W", RemoteDeviceType.TV, "TVs/Akai/AKAI_ATE_22Y604W.ir",
|
||||
"POWER" to "电源" to "0x01721EE1", "MUTE" to "静音" to "0x01725CA3", "VOL_UP" to "音量 +" to "0x01720AF5",
|
||||
"VOL_DOWN" to "音量 -" to "0x017206F9", "CH_UP" to "频道 +" to "0x017248B7", "CH_DOWN" to "频道 -" to "0x017244BB",
|
||||
"SOURCE" to "信号源" to "0x017207F8", "OK" to "确定" to "0x01725FA0", "UP" to "上" to "0x017203FC", "DOWN" to "下" to "0x01725EA1"),
|
||||
preset("acer_h6517st", "Acer H6517ST", RemoteDeviceType.PROJECTOR, "Projectors/Acer/Acer_H6517ST.ir",
|
||||
"POWER" to "电源" to "0x08138778", "SOURCE" to "信号源" to "0x08138C73", "SETUP" to "设置" to "0x08133BC4",
|
||||
"MODE" to "模式" to "0x0813807F", "ZOOM" to "缩放" to "0x08138B74", "MENU" to "菜单" to "0x0813847B",
|
||||
"UP" to "上" to "0x0813827D", "DOWN" to "下" to "0x0813857A", "LEFT" to "左" to "0x0813837C", "RIGHT" to "右" to "0x0813817E"),
|
||||
preset("nebula_capsule", "Anker Nebula Capsule", RemoteDeviceType.PROJECTOR, "Projectors/Anker/Nebula_Capsule_Mini_Projector.ir",
|
||||
"POWER" to "电源" to "0x807F51AE", "OK" to "确定" to "0x807F0DF2", "UP" to "上" to "0x807F26D9",
|
||||
"DOWN" to "下" to "0x807F28D7", "LEFT" to "左" to "0x807F25DA", "RIGHT" to "右" to "0x807F27D8",
|
||||
"MENU" to "菜单" to "0x807F49B6", "BACK" to "返回" to "0x807F1BE4", "HOME" to "主页" to "0x807F53AC"),
|
||||
preset("bose_solo_5", "Bose Solo 5", RemoteDeviceType.AUDIO, "SoundBars/Bose/Bose_Solo_5.ir",
|
||||
"POWER" to "电源" to "0xBAA04CB3", "MUTE" to "静音" to "0xBAA001FE", "VOL_UP" to "音量 +" to "0xBAA003FC",
|
||||
"VOL_DOWN" to "音量 -" to "0xBAA002FD", "PLAY_PAUSE" to "播放/暂停" to "0xBAA055AA", "NEXT" to "下一曲" to "0xBAA05AA5",
|
||||
"PREVIOUS" to "上一曲" to "0xBAA059A6", "BLUETOOTH" to "蓝牙" to "0xBAA0817E", "BASS" to "低音" to "0xBAA0827D"),
|
||||
preset("fosi_zd3", "Fosi Audio ZD3", RemoteDeviceType.AUDIO, "Audio_and_Video_Receivers/Fosi_Audio/Fosi_Audio_ZD3.ir",
|
||||
"POWER" to "电源" to "0xFA0105FA", "MUTE" to "静音" to "0xFA0102FD", "VOL_UP" to "音量 +" to "0xFA0103FC",
|
||||
"VOL_DOWN" to "音量 -" to "0xFA0107F8", "PLAY_PAUSE" to "播放/暂停" to "0xFA010AF5", "NEXT" to "下一曲" to "0xFA0106F9",
|
||||
"PREVIOUS" to "上一曲" to "0xFA0104FB", "OPTICAL" to "光纤" to "0xFA0108F7", "HDMI" to "HDMI" to "0xFA0113EC", "BLUETOOTH" to "蓝牙" to "0xFA0115EA"),
|
||||
preset("humax_generic", "Humax Cable Box", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Humax/Humax_generic_cable_box.ir",
|
||||
"POWER" to "电源" to "0x001000FF", "TV_RADIO" to "电视/广播" to "0x00100DF2", "SOURCE" to "信号源" to "0x001002FD",
|
||||
"VOL_UP" to "音量 +" to "0x00101FE0", "VOL_DOWN" to "音量 -" to "0x001040BF", "CH_UP" to "频道 +" to "0x001010EF", "CH_DOWN" to "频道 -" to "0x00100FF0"),
|
||||
preset("huawei_dn300", "Huawei DN300", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Huawei/Huawei_dn300_mgts(mts).ir",
|
||||
"POWER" to "电源" to "0x00BF43BC", "VOL_UP" to "音量 +" to "0x00BF52AD", "VOL_DOWN" to "音量 -" to "0x00BF53AC",
|
||||
"CH_UP" to "频道 +" to "0x00BF4BB4", "CH_DOWN" to "频道 -" to "0x00BF4CB3", "UP" to "上" to "0x00BF15EA",
|
||||
"DOWN" to "下" to "0x00BF16E9", "LEFT" to "左" to "0x00BF17E8", "RIGHT" to "右" to "0x00BF18E7", "BACK" to "返回" to "0x00BF51AE", "OK" to "确定" to "0x00BF19E6"),
|
||||
preset("firetv_stick_4k_max", "Amazon Fire TV Stick 4K Max", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Amazon/FireTV_Stick_4K_Max.ir",
|
||||
"POWER" to "电源" to "0x03FC1DE2", "MUTE" to "静音" to "0x007F4EB1", "VOL_UP" to "音量 +" to "0x03FC11EE", "VOL_DOWN" to "音量 -" to "0x03FC15EA"),
|
||||
preset("apple_tv_4k_gen2", "Apple TV 4K Gen 2", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Apple/Apple_TV_4K_Gen2.ir",
|
||||
"POWER_OFF" to "关机" to "0xE5872AFE", "POWER_ON" to "开机" to "0xE5872CFE", "TV" to "电视" to "0xE58740FE",
|
||||
"OK" to "确定" to "0xEE875C8C", "UP" to "上" to "0xEE870A8C", "DOWN" to "下" to "0xEE870C8C",
|
||||
"LEFT" to "左" to "0xEE87098C", "RIGHT" to "右" to "0xEE87068C", "BACK" to "返回" to "0xEE87038C", "PLAY_PAUSE" to "播放/暂停" to "0xEE875F8C"),
|
||||
preset("atomberg_fan", "Atomberg Fan", RemoteDeviceType.FAN, "Fans/Atomberg/Atomberg_fan.ir",
|
||||
"POWER" to "电源" to "0x00F3916E", "SPEED_1" to "风速 1" to "0x00F38B74", "SPEED_2" to "风速 2" to "0x00F3906F",
|
||||
"SPEED_3" to "风速 3" to "0x00F38A75", "SPEED_4" to "风速 4" to "0x00F3936C", "SPEED_5" to "风速 5" to "0x00F38877",
|
||||
"BOOST" to "强劲" to "0x00F38F70", "TIMER" to "定时" to "0x00F39669", "SLEEP" to "睡眠" to "0x00F38E71"),
|
||||
preset("daewoo_ddv200", "Daewoo DDV200", RemoteDeviceType.FAN, "Fans/Daewoo/Daewoo_DDV200.ir",
|
||||
"POWER" to "电源" to "0x00FF45BA", "SPEED" to "风速" to "0x00FF47B8", "WIND" to "风类" to "0x00FF40BF",
|
||||
"TIMER" to "定时" to "0x00FF19E6", "OSCILLATE" to "摇头" to "0x00FF1CE3"),
|
||||
preset("adj_uc_ir", "ADJ UC-IR Lighting", RemoteDeviceType.LIGHT, "LED_Lighting/ADJ/ADJ_UC-IR.ir",
|
||||
"POWER" to "电源" to "0x00FF47B8", "BRIGHTER" to "调亮" to "0x00FF43BC", "DIMMER" to "调暗" to "0x00FF09F6",
|
||||
"FULL_ON" to "全亮" to "0x00FF44BB", "FADE" to "渐变" to "0x00FF40BF", "STROBE" to "频闪" to "0x00FF07F8", "COLOR" to "颜色" to "0x00FF15EA"),
|
||||
preset("antizer_candles", "Antizer Flameless Candles", RemoteDeviceType.LIGHT, "LED_Lighting/Antizer_Flameless_Candles/Antizer_Flameless_Candles.ir",
|
||||
"ON" to "开启" to "0x08B700FF", "OFF" to "关闭" to "0x08B702FD", "DIMMER" to "调暗" to "0x08B710EF",
|
||||
"BRIGHTER" to "调亮" to "0x08B712ED", "CANDLE" to "烛光模式" to "0x08B70CF3", "LIGHT" to "常亮模式" to "0x08B70EF1"),
|
||||
)
|
||||
|
||||
fun preset(id: String?): OpenSourceIrPreset? = presets.firstOrNull { it.option.id == id }
|
||||
|
||||
private fun preset(
|
||||
id: String,
|
||||
name: String,
|
||||
type: RemoteDeviceType,
|
||||
sourcePath: String,
|
||||
vararg buttons: Pair<Pair<String, String>, String>,
|
||||
) = OpenSourceIrPreset(
|
||||
PresetBrandOption(id, name, type, sourcePath),
|
||||
buttons.map { (identity, code) -> PresetCodeLibrary.PresetButtonTemplate(identity.first, identity.second, code) },
|
||||
)
|
||||
|
||||
private infix fun Pair<String, String>.to(code: String): Pair<Pair<String, String>, String> = Pair(this, code)
|
||||
}
|
||||
+10
-20
@@ -4,30 +4,21 @@ data class PresetBrandOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val deviceType: RemoteDeviceType,
|
||||
val sourcePath: String? = null,
|
||||
)
|
||||
|
||||
object PresetCodeLibrary {
|
||||
|
||||
val availablePresets: List<PresetBrandOption> = listOf(
|
||||
// 电视预设
|
||||
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
|
||||
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
|
||||
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
|
||||
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
|
||||
|
||||
// 空调预设
|
||||
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
|
||||
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
|
||||
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
|
||||
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
|
||||
|
||||
// 风扇预设
|
||||
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
|
||||
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
|
||||
|
||||
// 自定义
|
||||
PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM),
|
||||
)
|
||||
) + OpenSourceIrCatalog.presets.map { it.option }
|
||||
|
||||
data class PresetButtonTemplate(
|
||||
val buttonId: String,
|
||||
@@ -39,8 +30,8 @@ object PresetCodeLibrary {
|
||||
type: RemoteDeviceType,
|
||||
presetId: String? = null,
|
||||
layoutStyle: PanelLayoutStyle? = null,
|
||||
): List<PresetButtonTemplate> = when (type) {
|
||||
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
|
||||
): List<PresetButtonTemplate> = OpenSourceIrCatalog.preset(presetId)?.buttons ?: when (type) {
|
||||
RemoteDeviceType.TV -> emptyList()
|
||||
RemoteDeviceType.AIR_CONDITIONER -> listOf(
|
||||
PresetButtonTemplate("AC_POWER", "电源"),
|
||||
PresetButtonTemplate("AC_TEMP_UP", "升温"),
|
||||
@@ -51,13 +42,12 @@ object PresetCodeLibrary {
|
||||
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
|
||||
PresetButtonTemplate("AC_ECO", "节能"),
|
||||
)
|
||||
RemoteDeviceType.FAN -> listOf(
|
||||
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
|
||||
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
|
||||
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
|
||||
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
|
||||
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
|
||||
)
|
||||
RemoteDeviceType.FAN,
|
||||
RemoteDeviceType.PROJECTOR,
|
||||
RemoteDeviceType.AUDIO,
|
||||
RemoteDeviceType.SET_TOP_BOX,
|
||||
RemoteDeviceType.STREAMING_DEVICE,
|
||||
RemoteDeviceType.LIGHT -> emptyList()
|
||||
RemoteDeviceType.CUSTOM -> when (layoutStyle) {
|
||||
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.flagship.abox.manager.domain.infrared
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.InfraredCode
|
||||
import com.flagship.abox.manager.domain.InfraredGateway
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
class TestPresetInfraredCode(
|
||||
private val gateway: InfraredGateway,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
presetId: String,
|
||||
): DomainResult<Unit> {
|
||||
val preset = OpenSourceIrCatalog.preset(presetId)
|
||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Preset is not testable"))
|
||||
val testButton = preset.buttons.firstOrNull { button ->
|
||||
button.buttonId.contains("POWER") || button.buttonId == "ON" || button.buttonId == "OFF"
|
||||
} ?: return DomainResult.Failure(DomainError.NoData(detail = "Preset has no test button"))
|
||||
val code = testButton.defaultHexCode
|
||||
?: return DomainResult.Failure(DomainError.NoData(detail = "Preset test code is unavailable"))
|
||||
val scratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT
|
||||
return when (val download = gateway.downloadCodes(profileId, listOf(InfraredCode(deviceName, scratchSlot, code)))) {
|
||||
is DomainResult.Failure -> download
|
||||
is DomainResult.Success -> gateway.sendKey(profileId, deviceName, scratchSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -10,6 +10,11 @@ enum class RemoteDeviceType {
|
||||
TV,
|
||||
AIR_CONDITIONER,
|
||||
FAN,
|
||||
PROJECTOR,
|
||||
AUDIO,
|
||||
SET_TOP_BOX,
|
||||
STREAMING_DEVICE,
|
||||
LIGHT,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
@@ -70,7 +75,12 @@ data class VirtualRemote(
|
||||
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
|
||||
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
|
||||
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
|
||||
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
|
||||
RemoteDeviceType.FAN,
|
||||
RemoteDeviceType.PROJECTOR,
|
||||
RemoteDeviceType.AUDIO,
|
||||
RemoteDeviceType.SET_TOP_BOX,
|
||||
RemoteDeviceType.STREAMING_DEVICE,
|
||||
RemoteDeviceType.LIGHT,
|
||||
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InputFile
|
||||
)
|
||||
|
||||
$lines = Get-Content -LiteralPath $InputFile
|
||||
$signals = @()
|
||||
|
||||
function Convert-Signal([string]$Protocol, [string]$Address, [string]$Command) {
|
||||
$addressBytes = $Address -split '\s+' | Where-Object { $_ }
|
||||
$commandBytes = $Command -split '\s+' | Where-Object { $_ }
|
||||
|
||||
if ($Protocol -eq 'NEC') {
|
||||
if ($addressBytes.Count -lt 1 -or $commandBytes.Count -lt 1) { return $null }
|
||||
$addressValue = [Convert]::ToInt32($addressBytes[0], 16)
|
||||
$commandValue = [Convert]::ToInt32($commandBytes[0], 16)
|
||||
return '0x{0:X2}{1:X2}{2:X2}{3:X2}' -f $addressValue, (255 - $addressValue), $commandValue, (255 - $commandValue)
|
||||
}
|
||||
|
||||
if ($Protocol -eq 'NECext') {
|
||||
if ($addressBytes.Count -lt 2 -or $commandBytes.Count -lt 2) { return $null }
|
||||
return '0x{0}{1}{2}{3}' -f $addressBytes[0].ToUpper(), $addressBytes[1].ToUpper(), $commandBytes[0].ToUpper(), $commandBytes[1].ToUpper()
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $lines.Count; $index++) {
|
||||
if ($lines[$index] -notmatch '^name:\s*(.+)$') { continue }
|
||||
|
||||
$name = $matches[1].Trim()
|
||||
$type = ''
|
||||
$protocol = ''
|
||||
$address = ''
|
||||
$command = ''
|
||||
|
||||
for ($lookahead = $index + 1; $lookahead -lt [Math]::Min($index + 8, $lines.Count); $lookahead++) {
|
||||
if ($lines[$lookahead] -match '^name:' -or $lines[$lookahead] -eq '#') {
|
||||
if ($lookahead -gt $index + 1) { break }
|
||||
}
|
||||
if ($lines[$lookahead] -match '^type:\s*(.+)$') { $type = $matches[1].Trim() }
|
||||
if ($lines[$lookahead] -match '^protocol:\s*(.+)$') { $protocol = $matches[1].Trim() }
|
||||
if ($lines[$lookahead] -match '^address:\s*(.+)$') { $address = $matches[1].Trim() }
|
||||
if ($lines[$lookahead] -match '^command:\s*(.+)$') { $command = $matches[1].Trim() }
|
||||
}
|
||||
|
||||
if ($type -ne 'parsed' -or $protocol -notin @('NEC', 'NECext')) { continue }
|
||||
$hexCode = Convert-Signal $protocol $address $command
|
||||
if ($null -ne $hexCode) {
|
||||
$signals += [pscustomobject]@{
|
||||
name = $name
|
||||
protocol = $protocol
|
||||
code = $hexCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$signals | ConvertTo-Json -Depth 3
|
||||
Reference in New Issue
Block a user