[web] Refactor management of remotes and outputs

This commit is contained in:
Alain Nussbaumer 2025-04-27 16:10:30 +02:00
parent 95de42e6be
commit 786d8cbc09
16 changed files with 172 additions and 153 deletions

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<component :is="Component" /> <component :is="Component" />
</router-view> </router-view>
<modal-dialog-remote-pairing <modal-dialog-remote-pairing
:show="pairingActive" :show="remotesStore.active"
@close="pairingActive = false" @close="remotesStore.active = false"
/> />
<modal-dialog-update <modal-dialog-update
:show="uiStore.showUpdateDialog" :show="uiStore.showUpdateDialog"
@ -67,7 +67,6 @@ export default {
}, },
data() { data() {
return { return {
pairingActive: false,
timerId: 0 timerId: 0
} }
}, },
@ -254,7 +253,6 @@ export default {
updatePairing() { updatePairing() {
webapi.pairing().then((data) => { webapi.pairing().then((data) => {
this.remotesStore.$state = data this.remotesStore.$state = data
this.pairingActive = data.active
}) })
}, },
updatePlayerStatus() { updatePlayerStatus() {

View File

@ -0,0 +1,41 @@
<template>
<div class="field is-grouped">
<div class="control">
<input
ref="input"
v-model="value"
class="input"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="placeholder"
@input="validate"
/>
</div>
<slot />
</div>
</template>
<script>
export default {
name: 'ControlPinField',
props: {
placeholder: { required: true, type: String }
},
emits: ['input'],
data() {
return { value: '' }
},
mounted() {
setTimeout(() => {
this.$refs.input.focus()
}, 10)
},
methods: {
validate(event) {
const { validity } = event.target
const invalid = validity.patternMismatch || validity.valueMissing
this.$emit('input', this.value, invalid)
}
}
}
</script>

View File

@ -29,10 +29,7 @@ export default {
}, },
emits: ['input'], emits: ['input'],
data() { data() {
return { return { value: '' }
disabled: true,
value: ''
}
}, },
mounted() { mounted() {
setTimeout(() => { setTimeout(() => {
@ -42,8 +39,8 @@ export default {
methods: { methods: {
validate(event) { validate(event) {
const { validity } = event.target const { validity } = event.target
this.disabled = validity.patternMismatch || validity.valueMissing const invalid = validity.patternMismatch || validity.valueMissing
this.$emit('input', this.value, this.disabled) this.$emit('input', this.value, invalid)
} }
} }
} }

View File

@ -7,32 +7,25 @@
> >
<template #content> <template #content>
<form @submit.prevent="pair"> <form @submit.prevent="pair">
<label class="label" v-text="pairing.remote" /> <label class="label" v-text="remoteStore.remote" />
<div class="field"> <control-pin-field
<div class="control"> :placeholder="$t('dialog.remote-pairing.pairing-code')"
<input @input="onPinChange"
ref="pin_field" />
v-model="pairing_req.pin"
class="input"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="$t('dialog.remote-pairing.pairing-code')"
/>
</div>
</div>
</form> </form>
</template> </template>
</modal-dialog> </modal-dialog>
</template> </template>
<script> <script>
import ControlPinField from '@/components/ControlPinField.vue'
import ModalDialog from '@/components/ModalDialog.vue' import ModalDialog from '@/components/ModalDialog.vue'
import { useRemotesStore } from '@/stores/remotes' import { useRemotesStore } from '@/stores/remotes'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'ModalDialogRemotePairing', name: 'ModalDialogRemotePairing',
components: { ModalDialog }, components: { ControlPinField, ModalDialog },
props: { show: Boolean }, props: { show: Boolean },
emits: ['close'], emits: ['close'],
setup() { setup() {
@ -40,38 +33,34 @@ export default {
}, },
data() { data() {
return { return {
pairing_req: { pin: '' } disabled: true,
pin: ''
} }
}, },
computed: { computed: {
actions() { actions() {
return [ return [
{ handler: this.cancel, icon: 'cancel', key: 'actions.cancel' }, { handler: this.cancel, icon: 'cancel', key: 'actions.cancel' },
{ handler: this.pair, icon: 'cellphone', key: 'actions.pair' } {
disabled: this.disabled,
handler: this.pair,
icon: 'vector-link',
key: 'actions.pair'
}
] ]
},
pairing() {
return this.remoteStore.pairing
}
},
watch: {
show() {
if (this.show) {
this.loading = false
// Delay setting the focus on the input field until it is part of the DOM and visible
setTimeout(() => {
this.$refs.pin_field.focus()
}, 10)
}
} }
}, },
methods: { methods: {
cancel() { cancel() {
this.$emit('close') this.$emit('close')
}, },
onPinChange(pin, disabled) {
this.pin = pin
this.disabled = disabled
},
pair() { pair() {
webapi.pairing_kickoff(this.pairing_req).then(() => { webapi.pairing_kickoff({ pin: this.pin }).then(() => {
this.pairing_req.pin = '' this.pin = ''
}) })
} }
} }

View File

@ -16,8 +16,8 @@ export default {
to: { name: 'settings-webinterface' } to: { name: 'settings-webinterface' }
}, },
{ {
key: 'page.settings.tabs.remotes-and-outputs', key: 'page.settings.tabs.devices',
to: { name: 'settings-remotes-outputs' } to: { name: 'settings-devices' }
}, },
{ {
key: 'page.settings.tabs.artwork', key: 'page.settings.tabs.artwork',

View File

@ -17,7 +17,6 @@
"remove": "Entfernen", "remove": "Entfernen",
"rescan": "Neu einlesen", "rescan": "Neu einlesen",
"save": "Speichern", "save": "Speichern",
"send": "Senden",
"show-more": "Zeige mehr", "show-more": "Zeige mehr",
"shuffle": "Zufallswiedergabe", "shuffle": "Zufallswiedergabe",
"update": "Neu einlesen", "update": "Neu einlesen",
@ -314,9 +313,9 @@
}, },
"tabs": { "tabs": {
"artwork": "Artwork", "artwork": "Artwork",
"devices": "Fernbedienungen und Ausgänge",
"general": "Allgemein", "general": "Allgemein",
"online-services": "Online-Services", "online-services": "Online-Services"
"remotes-and-outputs": "Fernbedienungen und Ausgänge"
} }
}, },
"spotify": { "spotify": {

View File

@ -17,7 +17,6 @@
"remove": "Remove", "remove": "Remove",
"rescan": "Rescan", "rescan": "Rescan",
"save": "Save", "save": "Save",
"send": "Send",
"show-more": "Show more", "show-more": "Show more",
"shuffle": "Shuffle", "shuffle": "Shuffle",
"update": "Update", "update": "Update",
@ -314,9 +313,9 @@
}, },
"tabs": { "tabs": {
"artwork": "Artwork", "artwork": "Artwork",
"devices": "Remotes and Outputs",
"general": "General", "general": "General",
"online-services": "Online Services", "online-services": "Online Services"
"remotes-and-outputs": "Remotes and Outputs"
} }
}, },
"spotify": { "spotify": {

View File

@ -17,7 +17,6 @@
"remove": "Supprimer", "remove": "Supprimer",
"rescan": "Analyser", "rescan": "Analyser",
"save": "Enregistrer", "save": "Enregistrer",
"send": "Envoyer",
"show-more": "Afficher plus", "show-more": "Afficher plus",
"shuffle": "Lecture aléatoire", "shuffle": "Lecture aléatoire",
"update": "Actualiser", "update": "Actualiser",
@ -314,9 +313,9 @@
}, },
"tabs": { "tabs": {
"artwork": "Illustrations", "artwork": "Illustrations",
"devices": "Télécommandes et sorties",
"general": "Général", "general": "Général",
"online-services": "Services en ligne", "online-services": "Services en ligne"
"remotes-and-outputs": "Télécommandes et sorties"
} }
}, },
"spotify": { "spotify": {

View File

@ -17,7 +17,6 @@
"remove": "移除", "remove": "移除",
"rescan": "重新扫描", "rescan": "重新扫描",
"save": "保存", "save": "保存",
"send": "发送",
"show-more": "显示更多", "show-more": "显示更多",
"shuffle": "随机播放", "shuffle": "随机播放",
"update": "更新", "update": "更新",
@ -314,9 +313,9 @@
}, },
"tabs": { "tabs": {
"artwork": "封面", "artwork": "封面",
"devices": "遥控和输出",
"general": "概览", "general": "概览",
"online-services": "在线服务", "online-services": "在线服务"
"remotes-and-outputs": "遥控和输出"
} }
}, },
"spotify": { "spotify": {

View File

@ -17,7 +17,6 @@
"remove": "移除", "remove": "移除",
"rescan": "重新掃描", "rescan": "重新掃描",
"save": "儲存", "save": "儲存",
"send": "發送",
"show-more": "顯示更多", "show-more": "顯示更多",
"shuffle": "隨機播放", "shuffle": "隨機播放",
"update": "更新", "update": "更新",
@ -314,9 +313,9 @@
}, },
"tabs": { "tabs": {
"artwork": "封面", "artwork": "封面",
"devices": "遙控和輸出",
"general": "概覽", "general": "概覽",
"online-services": "在線服務", "online-services": "在線服務"
"remotes-and-outputs": "遙控和輸出"
} }
}, },
"spotify": { "spotify": {

View File

@ -8,7 +8,6 @@ import {
mdiCancel, mdiCancel,
mdiCast, mdiCast,
mdiCastVariant, mdiCastVariant,
mdiCellphone,
mdiCheck, mdiCheck,
mdiChevronDown, mdiChevronDown,
mdiChevronLeft, mdiChevronLeft,
@ -59,6 +58,7 @@ import {
mdiSpeaker, mdiSpeaker,
mdiSpotify, mdiSpotify,
mdiStop, mdiStop,
mdiVectorLink,
mdiVolumeHigh, mdiVolumeHigh,
mdiVolumeOff, mdiVolumeOff,
mdiWeb mdiWeb
@ -74,7 +74,6 @@ export const icons = {
mdiCancel, mdiCancel,
mdiCast, mdiCast,
mdiCastVariant, mdiCastVariant,
mdiCellphone,
mdiCheck, mdiCheck,
mdiChevronDown, mdiChevronDown,
mdiChevronLeft, mdiChevronLeft,
@ -125,6 +124,7 @@ export const icons = {
mdiSpeaker, mdiSpeaker,
mdiSpotify, mdiSpotify,
mdiStop, mdiStop,
mdiVectorLink,
mdiVolumeHigh, mdiVolumeHigh,
mdiVolumeOff, mdiVolumeOff,
mdiWeb mdiWeb

View File

@ -7,32 +7,25 @@
/> />
</template> </template>
<template #content> <template #content>
<div v-if="pairing.active"> <form v-if="remotesStore.active" @submit.prevent="pairRemote">
<form @submit.prevent="kickoffPairing"> <label class="label has-text-weight-normal content">
<label class="label has-text-weight-normal content"> <span v-text="$t('page.settings.devices.pairing-request')" />
<span v-text="$t('page.settings.devices.pairing-request')" /> <b v-text="remotesStore.remote" />
<b v-text="pairing.remote" /> </label>
</label> <control-pin-field
<div class="field is-grouped"> :placeholder="$t('dialog.remote-pairing.pairing-code')"
<div class="control"> @input="onRemotePinChange"
<input >
v-model="pairingRequest.pin" <div class="control">
class="input" <button
inputmode="numeric" class="button"
pattern="[\d]{4}" type="submit"
:placeholder="$t('page.settings.devices.pairing-code')" :disabled="remotePairingDisabled"
/> v-text="$t('actions.verify')"
</div> />
<div class="control">
<button
class="button"
type="submit"
v-text="$t('actions.send')"
/>
</div>
</div> </div>
</form> </control-pin-field>
</div> </form>
<div v-else v-text="$t('page.settings.devices.no-active-pairing')" /> <div v-else v-text="$t('page.settings.devices.no-active-pairing')" />
</template> </template>
</content-with-heading> </content-with-heading>
@ -47,7 +40,7 @@
class="content" class="content"
v-text="$t('page.settings.devices.speaker-pairing-info')" v-text="$t('page.settings.devices.speaker-pairing-info')"
/> />
<div v-for="output in outputs" :key="output.id"> <div v-for="output in outputs" :key="output.id" class="field is-grouped">
<control-switch <control-switch
v-model="output.selected" v-model="output.selected"
@update:model-value="toggleOutput(output.id)" @update:model-value="toggleOutput(output.id)"
@ -58,19 +51,12 @@
</control-switch> </control-switch>
<form <form
v-if="output.needs_auth_key" v-if="output.needs_auth_key"
class="mb-5" @submit.prevent="pairOutput(output.id)"
@submit.prevent="kickoffVerification(output.id)"
> >
<div class="field is-grouped"> <control-pin-field
<div class="control"> :placeholder="$t('page.settings.devices.verification-code')"
<input @input="onOutputPinChange"
v-model="verificationRequest.pin" >
class="input"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="$t('page.settings.devices.verification-code')"
/>
</div>
<div class="control"> <div class="control">
<button <button
class="button" class="button"
@ -78,7 +64,7 @@
v-text="$t('actions.verify')" v-text="$t('actions.verify')"
/> />
</div> </div>
</div> </control-pin-field>
</form> </form>
</div> </div>
</template> </template>
@ -87,6 +73,7 @@
<script> <script>
import ContentWithHeading from '@/templates/ContentWithHeading.vue' import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ControlPinField from '@/components/ControlPinField.vue'
import ControlSwitch from '@/components/ControlSwitch.vue' import ControlSwitch from '@/components/ControlSwitch.vue'
import HeadingTitle from '@/components/HeadingTitle.vue' import HeadingTitle from '@/components/HeadingTitle.vue'
import TabsSettings from '@/components/TabsSettings.vue' import TabsSettings from '@/components/TabsSettings.vue'
@ -95,31 +82,42 @@ import { useRemotesStore } from '@/stores/remotes'
import webapi from '@/webapi' import webapi from '@/webapi'
export default { export default {
name: 'PageSettingsRemotesOutputs', name: 'PageSettingsDevices',
components: { ContentWithHeading, ControlSwitch, HeadingTitle, TabsSettings }, components: {
ContentWithHeading,
ControlPinField,
ControlSwitch,
HeadingTitle,
TabsSettings
},
setup() { setup() {
return { outputsStore: useOutputsStore(), remotesStore: useRemotesStore() } return { outputsStore: useOutputsStore(), remotesStore: useRemotesStore() }
}, },
data() { data() {
return { return {
pairingRequest: { pin: '' }, outputPin: '',
verificationRequest: { pin: '' } remotePairingDisabled: true,
remotePin: ''
} }
}, },
computed: { computed: {
outputs() { outputs() {
return this.outputsStore.outputs return this.outputsStore.outputs
},
pairing() {
return this.remotesStore.pairing
} }
}, },
methods: { methods: {
kickoffPairing() { pairRemote() {
webapi.pairing_kickoff(this.pairingRequest) webapi.pairing_kickoff({ pin: this.remotePin })
}, },
kickoffVerification(identifier) { pairOutput(identifier) {
webapi.output_update(identifier, this.verificationRequest) webapi.output_update(identifier, { pin: this.outputPin })
},
onRemotePinChange(pin, disabled) {
this.remotePin = pin
this.remotePairingDisabled = disabled
},
onOutputPinChange(pin) {
this.outputPin = pin
}, },
toggleOutput(identifier) { toggleOutput(identifier) {
webapi.output_toggle(identifier) webapi.output_toggle(identifier)

View File

@ -36,8 +36,8 @@ import PageRadioStreams from '@/pages/PageRadioStreams.vue'
import PageSearchLibrary from '@/pages/PageSearchLibrary.vue' import PageSearchLibrary from '@/pages/PageSearchLibrary.vue'
import PageSearchSpotify from '@/pages/PageSearchSpotify.vue' import PageSearchSpotify from '@/pages/PageSearchSpotify.vue'
import PageSettingsArtwork from '@/pages/PageSettingsArtwork.vue' import PageSettingsArtwork from '@/pages/PageSettingsArtwork.vue'
import PageSettingsDevices from '@/pages/PageSettingsDevices.vue'
import PageSettingsOnlineServices from '@/pages/PageSettingsOnlineServices.vue' import PageSettingsOnlineServices from '@/pages/PageSettingsOnlineServices.vue'
import PageSettingsRemotesOutputs from '@/pages/PageSettingsRemotesOutputs.vue'
import PageSettingsWebinterface from '@/pages/PageSettingsWebinterface.vue' import PageSettingsWebinterface from '@/pages/PageSettingsWebinterface.vue'
const TOP_WITH_TABS = 100 const TOP_WITH_TABS = 100
@ -233,9 +233,9 @@ export const router = createRouter({
path: '/settings/online-services' path: '/settings/online-services'
}, },
{ {
component: PageSettingsRemotesOutputs, component: PageSettingsDevices,
name: 'settings-remotes-outputs', name: 'settings-devices',
path: '/settings/remotes-outputs' path: '/settings/devices'
} }
], ],
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
export const useRemotesStore = defineStore('RemotesStore', { export const useRemotesStore = defineStore('RemotesStore', {
state: () => ({ state: () => ({
pairing: {} active: false,
remote: ''
}) })
}) })

View File

@ -241,8 +241,8 @@ export default {
return axios.get('./api/pairing') return axios.get('./api/pairing')
}, },
pairing_kickoff(pairingReq) { pairing_kickoff(request) {
return axios.post('./api/pairing', pairingReq) return axios.post('./api/pairing', request)
}, },
player_consume(state) { player_consume(state) {