ENH - migrate Radio to Sky UI

This commit is contained in:
DerEchteAlec
2026-08-13 03:10:19 +02:00
parent ac5033811b
commit 2cf15c70be
6 changed files with 611 additions and 386 deletions
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<defs>
<linearGradient id="radio-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a84ff"/>
<stop offset="1" stop-color="#0056b3"/>
</linearGradient>
</defs>
<rect width="120" height="120" rx="26" fill="url(#radio-bg)"/>
<g transform="translate(60 60)" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round">
<path d="M0-10V-38"/>
<circle cx="0" cy="-40" r="3" fill="#fff" stroke="none"/>
<rect x="-22" y="-10" width="44" height="48" rx="6"/>
<path d="M-10 4H10M-10 12H10M-10 20H10M18-26c10 6 10 20 0 26M24-30c14 10 14 28 0 38"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -47,7 +47,7 @@ import mapIcon from '@/assets/img/app-icons/map.webp'
import messagesIcon from '@/assets/img/app-icons/sms.webp' import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp' import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp' import notesIcon from '@/assets/img/app-icons/notes.webp'
import radioIcon from '@/assets/img/app-icons/radio.svg' import radioIcon from '@/assets/img/app-icons/radio.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg' import settingsIcon from '@/assets/img/app-icons/settings.svg'
+279
View File
@@ -0,0 +1,279 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useRadioStore } from '@/stores/radio'
import type { RadioData } from '@/types/radio'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const radioData: RadioData = {
badge: '231',
badgeEnabled: true,
badgeMaxLength: 8,
connected: false,
displayName: 'Unit 21',
displayNameAllowed: true,
displayNameEnabled: true,
displayNameMaxLength: 32,
frequency: 0,
frequencyMax: 999.9,
frequencyMin: 0.1,
frequencyStep: 0.1,
history: [{ primary: 120.5, secondary: 130.7 }],
members: [],
provider: 'yaca',
secondaryFrequency: 0,
secondarySupported: true,
settings: { autoRejoin: false, notifications: true },
volume: 50,
}
describe('radio store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('hydrates the complete server-authoritative radio state', async () => {
mockNuiCall.mockResolvedValueOnce({ data: radioData, success: true })
const radio = useRadioStore()
await radio.load()
expect(radio.data).toMatchObject(radioData)
expect(radio.error).toBe('')
expect(mockNuiCall).toHaveBeenCalledWith('radio:get')
})
it('sends both selected channels and applies the accepted connection', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
connected: true,
frequency: 120.5,
secondaryFrequency: 130.7,
},
success: true,
})
const radio = useRadioStore()
expect(await radio.connect(120.5, 130.7)).toBe(true)
expect(radio.data.connected).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('radio:connect', {
frequency: 120.5,
secondaryFrequency: 130.7,
})
})
it('keeps the current connection when disconnect is rejected', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'voice_unavailable',
success: false,
})
const radio = useRadioStore()
radio.data.connected = true
radio.data.frequency = 120.5
await radio.disconnect()
expect(radio.data.connected).toBe(true)
expect(radio.data.frequency).toBe(120.5)
expect(radio.error).toBe('voice_unavailable')
expect(radio.isLoading).toBe(false)
})
it('rolls an optimistic setting change back after server rejection', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_setting',
success: false,
})
const radio = useRadioStore()
await radio.saveSetting('autoRejoin', true)
expect(radio.data.settings.autoRejoin).toBe(false)
expect(radio.error).toBe('invalid_setting')
})
it('ignores a stale volume response that arrives after the newest value', async () => {
let resolveFirst!: (value: {
data: { volume: number }
success: true
}) => void
let resolveSecond!: (value: {
data: { volume: number }
success: true
}) => void
mockNuiCall
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
}),
)
const radio = useRadioStore()
const first = radio.setVolume(25)
const second = radio.setVolume(75)
resolveSecond({ data: { volume: 75 }, success: true })
await second
resolveFirst({ data: { volume: 25 }, success: true })
await first
expect(radio.data.volume).toBe(75)
})
it('applies canonical profile values accepted by the server', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: { badge: 'A_1' }, success: true })
.mockResolvedValueOnce({
data: { displayName: 'Unit Seven' },
success: true,
})
const radio = useRadioStore()
expect(await radio.saveBadge('a_1')).toBe(true)
expect(await radio.saveDisplayName(' Unit Seven ')).toBe(true)
expect(radio.data.badge).toBe('A_1')
expect(radio.data.displayName).toBe('Unit Seven')
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'radio:save-badge', {
badge: 'a_1',
})
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'radio:save-display-name', {
displayName: ' Unit Seven ',
})
})
it('keeps the authoritative profile value when saving is rejected', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'rate_limited',
success: false,
})
const radio = useRadioStore()
radio.data.badge = '231'
expect(await radio.saveBadge('232')).toBe(false)
expect(radio.data.badge).toBe('231')
expect(radio.error).toBe('rate_limited')
})
it('ignores stale badge responses after a newer save', async () => {
let resolveFirst!: (value: {
data: { badge: string }
success: true
}) => void
let resolveSecond!: (value: {
data: { badge: string }
success: true
}) => void
mockNuiCall
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
}),
)
const radio = useRadioStore()
const first = radio.saveBadge('231')
const second = radio.saveBadge('232')
resolveSecond({ data: { badge: '232' }, success: true })
await second
resolveFirst({ data: { badge: '231' }, success: true })
await first
expect(radio.data.badge).toBe('232')
})
it('ignores stale display-name responses after a newer save', async () => {
let resolveFirst!: (value: {
data: { displayName: string }
success: true
}) => void
let resolveSecond!: (value: {
data: { displayName: string }
success: true
}) => void
mockNuiCall
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve
}),
)
const radio = useRadioStore()
const first = radio.saveDisplayName('Unit One')
const second = radio.saveDisplayName('Unit Two')
resolveSecond({ data: { displayName: 'Unit Two' }, success: true })
await second
resolveFirst({ data: { displayName: 'Unit One' }, success: true })
await first
expect(radio.data.displayName).toBe('Unit Two')
})
it('merges setting responses per key without clobbering another toggle', async () => {
let resolveAutoRejoin!: (value: {
data: RadioData['settings']
success: true
}) => void
let resolveNotifications!: (value: {
data: RadioData['settings']
success: true
}) => void
mockNuiCall
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveAutoRejoin = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveNotifications = resolve
}),
)
const radio = useRadioStore()
const autoRejoin = radio.saveSetting('autoRejoin', true)
const notifications = radio.saveSetting('notifications', true)
resolveNotifications({
data: { autoRejoin: false, notifications: true },
success: true,
})
await notifications
resolveAutoRejoin({
data: { autoRejoin: true, notifications: false },
success: true,
})
await autoRejoin
expect(radio.data.settings).toEqual({
autoRejoin: true,
notifications: true,
})
})
})
+21 -2
View File
@@ -30,6 +30,13 @@ export const useRadioStore = defineStore('radio', () => {
const data = reactive<RadioData>(structuredClone(defaults)) const data = reactive<RadioData>(structuredClone(defaults))
const error = ref('') const error = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const settingRequestIds: Record<keyof RadioSettings, number> = {
autoRejoin: 0,
notifications: 0,
}
let badgeRequestId = 0
let displayNameRequestId = 0
let volumeRequestId = 0
function apply(next: Partial<RadioData>): void { function apply(next: Partial<RadioData>): void {
Object.assign(data, next) Object.assign(data, next)
@@ -61,10 +68,12 @@ export const useRadioStore = defineStore('radio', () => {
} }
async function disconnect(): Promise<void> { async function disconnect(): Promise<void> {
isLoading.value = true
error.value = '' error.value = ''
const response = await nuiCall('radio:disconnect') const response = await nuiCall('radio:disconnect')
if (!response.success) { if (!response.success) {
error.value = response.error ?? 'request_failed' error.value = response.error ?? 'request_failed'
isLoading.value = false
return return
} }
apply({ apply({
@@ -73,13 +82,16 @@ export const useRadioStore = defineStore('radio', () => {
members: [], members: [],
secondaryFrequency: 0, secondaryFrequency: 0,
}) })
isLoading.value = false
} }
async function setVolume(volume: number): Promise<void> { async function setVolume(volume: number): Promise<void> {
const requestId = ++volumeRequestId
data.volume = volume data.volume = volume
const response = await nuiCall<{ volume: number }>('radio:set-volume', { const response = await nuiCall<{ volume: number }>('radio:set-volume', {
volume, volume,
}) })
if (requestId !== volumeRequestId) return
if (response.success && response.data) data.volume = response.data.volume if (response.success && response.data) data.volume = response.data.volume
} }
@@ -87,35 +99,42 @@ export const useRadioStore = defineStore('radio', () => {
key: keyof RadioSettings, key: keyof RadioSettings,
value: boolean, value: boolean,
): Promise<void> { ): Promise<void> {
const requestId = ++settingRequestIds[key]
const previous = data.settings[key] const previous = data.settings[key]
data.settings[key] = value data.settings[key] = value
const response = await nuiCall<RadioSettings>('radio:save-settings', { const response = await nuiCall<RadioSettings>('radio:save-settings', {
key, key,
value, value,
}) })
if (response.success && response.data) data.settings = response.data if (requestId !== settingRequestIds[key]) return
else { if (response.success && response.data) {
data.settings[key] = response.data[key]
} else {
data.settings[key] = previous data.settings[key] = previous
error.value = response.error ?? 'request_failed' error.value = response.error ?? 'request_failed'
} }
} }
async function saveBadge(badge: string): Promise<boolean> { async function saveBadge(badge: string): Promise<boolean> {
const requestId = ++badgeRequestId
error.value = '' error.value = ''
const response = await nuiCall<{ badge: string }>('radio:save-badge', { const response = await nuiCall<{ badge: string }>('radio:save-badge', {
badge, badge,
}) })
if (requestId !== badgeRequestId) return true
if (response.success && response.data) data.badge = response.data.badge if (response.success && response.data) data.badge = response.data.badge
else error.value = response.error ?? 'request_failed' else error.value = response.error ?? 'request_failed'
return response.success return response.success
} }
async function saveDisplayName(displayName: string): Promise<boolean> { async function saveDisplayName(displayName: string): Promise<boolean> {
const requestId = ++displayNameRequestId
error.value = '' error.value = ''
const response = await nuiCall<{ displayName: string }>( const response = await nuiCall<{ displayName: string }>(
'radio:save-display-name', 'radio:save-display-name',
{ displayName }, { displayName },
) )
if (requestId !== displayNameRequestId) return true
if (response.success && response.data) if (response.success && response.data)
data.displayName = response.data.displayName data.displayName = response.data.displayName
else error.value = response.error ?? 'request_failed' else error.value = response.error ?? 'request_failed'
+310 -368
View File
@@ -1,20 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kPreloader,
kRange,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
import { import {
Clock3, Clock3,
RadioTower, RadioTower,
@@ -23,11 +7,30 @@ import {
Users, Users,
Volume2, Volume2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { usePhoneStore } from '@/stores/phone' import { usePhoneStore } from '@/stores/phone'
import { useRadioStore } from '@/stores/radio' import { useRadioStore } from '@/stores/radio'
import type { RadioHistoryEntry } from '@/types/radio' import type { RadioHistoryEntry } from '@/types/radio'
import {
SkyAppPage,
SkyButton,
SkyEmptyState,
SkyField,
SkyList,
SkyListItem,
SkyNavbar,
SkyRange,
SkyScrollArea,
SkySection,
SkySegmented,
SkySegmentedButton,
SkySettingsGroup,
SkySettingsRow,
SkySpinner,
SkyStatusCard,
SkyToast,
} from '@/ui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages' import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RadioTab = 'radio' | 'settings' type RadioTab = 'radio' | 'settings'
@@ -40,9 +43,11 @@ const secondaryInput = ref('')
const badgeInput = ref('') const badgeInput = ref('')
const displayNameInput = ref('') const displayNameInput = ref('')
const feedback = ref('') const feedback = ref('')
const volumeInput = ref(50)
const now = ref(Date.now()) const now = ref(Date.now())
const memberSnapshotAt = ref(Date.now()) const memberSnapshotAt = ref(Date.now())
let clockHandle: number | null = null let clockHandle: number | null = null
let feedbackHandle: number | null = null
const statusText = computed(() => { const statusText = computed(() => {
if (!radio.data.connected) return phone.t('Apps.radio.disconnected') if (!radio.data.connected) return phone.t('Apps.radio.disconnected')
@@ -54,9 +59,11 @@ const statusText = computed(() => {
}) })
}) })
function eventValue(event: Event): string { const providerText = computed(() => {
return (event.target as HTMLInputElement).value const provider = radio.data.provider
} if (!provider) return phone.t('Apps.radio.noProvider')
return provider.replace(/^./, (character) => character.toLocaleUpperCase())
})
function parseFrequency(value: string): number { function parseFrequency(value: string): number {
return Number.parseFloat(value.replace(',', '.')) return Number.parseFloat(value.replace(',', '.'))
@@ -66,6 +73,15 @@ function errorText(code: string): string {
return phone.t(`Apps.radio.errors.${code || 'default'}`) return phone.t(`Apps.radio.errors.${code || 'default'}`)
} }
function showFeedback(message: string): void {
if (feedbackHandle !== null) window.clearTimeout(feedbackHandle)
feedback.value = message
feedbackHandle = window.setTimeout(() => {
feedback.value = ''
feedbackHandle = null
}, 2500)
}
async function connect( async function connect(
primary = parseFrequency(primaryInput.value), primary = parseFrequency(primaryInput.value),
secondary = parseFrequency(secondaryInput.value) || 0, secondary = parseFrequency(secondaryInput.value) || 0,
@@ -74,6 +90,7 @@ async function connect(
radio.error = 'invalid_frequency' radio.error = 'invalid_frequency'
return return
} }
const connected = await radio.connect(primary, secondary) const connected = await radio.connect(primary, secondary)
if (connected) { if (connected) {
memberSnapshotAt.value = Date.now() memberSnapshotAt.value = Date.now()
@@ -88,6 +105,10 @@ async function disconnect(): Promise<void> {
await radio.disconnect() await radio.disconnect()
} }
function saveVolume(): void {
void radio.setVolume(volumeInput.value)
}
function connectHistory(entry: RadioHistoryEntry): void { function connectHistory(entry: RadioHistoryEntry): void {
primaryInput.value = String(entry.primary) primaryInput.value = String(entry.primary)
secondaryInput.value = entry.secondary ? String(entry.secondary) : '' secondaryInput.value = entry.secondary ? String(entry.secondary) : ''
@@ -127,29 +148,40 @@ function normalizedDisplayName(): string {
return clean return clean
} }
async function saveRadioProfile(): Promise<void> { async function saveDisplayNameSetting(): Promise<void> {
if (radio.data.displayNameEnabled && radio.data.displayNameAllowed) { if (!radio.data.displayNameEnabled || !radio.data.displayNameAllowed) return
const displayNameSaved = await radio.saveDisplayName(
normalizedDisplayName(), const displayName = normalizedDisplayName()
) if (displayName === radio.data.displayName) return
if (!displayNameSaved) {
feedback.value = errorText(radio.error) const saved = await radio.saveDisplayName(displayName)
window.setTimeout(() => (feedback.value = ''), 2500) if (displayNameInput.value !== displayName) return
return
} if (!saved) {
displayNameInput.value = radio.data.displayName
showFeedback(errorText(radio.error))
return
} }
if (radio.data.badgeEnabled) { displayNameInput.value = radio.data.displayName
const badgeSaved = await radio.saveBadge(normalizedBadge()) }
if (!badgeSaved) {
feedback.value = errorText(radio.error) async function saveBadgeSetting(): Promise<void> {
window.setTimeout(() => (feedback.value = ''), 2500) if (!radio.data.badgeEnabled) return
return
} const badge = normalizedBadge()
if (badge === radio.data.badge) return
const saved = await radio.saveBadge(badge)
if (badgeInput.value !== badge) return
if (!saved) {
badgeInput.value = radio.data.badge
showFeedback(errorText(radio.error))
return
} }
feedback.value = phone.t('Apps.radio.profileSaved') badgeInput.value = radio.data.badge
window.setTimeout(() => (feedback.value = ''), 2500)
} }
function onMessage(event: MessageEvent): void { function onMessage(event: MessageEvent): void {
@@ -160,6 +192,11 @@ function onMessage(event: MessageEvent): void {
} }
} }
watch(
() => radio.data.volume,
(volume) => (volumeInput.value = volume),
)
onMounted(async () => { onMounted(async () => {
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
clockHandle = window.setInterval(() => (now.value = Date.now()), 1000) clockHandle = window.setInterval(() => (now.value = Date.now()), 1000)
@@ -167,399 +204,304 @@ onMounted(async () => {
memberSnapshotAt.value = Date.now() memberSnapshotAt.value = Date.now()
badgeInput.value = radio.data.badge badgeInput.value = radio.data.badge
displayNameInput.value = radio.data.displayName displayNameInput.value = radio.data.displayName
volumeInput.value = radio.data.volume
if (radio.data.frequency) primaryInput.value = String(radio.data.frequency) if (radio.data.frequency) primaryInput.value = String(radio.data.frequency)
if (radio.data.secondaryFrequency) if (radio.data.secondaryFrequency) {
secondaryInput.value = String(radio.data.secondaryFrequency) secondaryInput.value = String(radio.data.secondaryFrequency)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
if (clockHandle) window.clearInterval(clockHandle) if (clockHandle !== null) window.clearInterval(clockHandle)
if (feedbackHandle !== null) window.clearTimeout(feedbackHandle)
}) })
</script> </script>
<template> <template>
<k-page component="main" class="radio-app"> <SkyAppPage
<k-navbar :title="phone.t('Apps.radio.name')"> class="radio-app"
<template #subnavbar> accent="#0a84ff"
<k-segmented :key="tab" strong rounded class="radio-tabs"> accent-soft="rgba(10, 132, 255, 0.16)"
<k-segmented-button :active="tab === 'radio'" @click="tab = 'radio'"> :dark="phone.isDarkMode"
<RadioTower :size="17" /> :label="phone.t('Apps.radio.name')"
{{ phone.t('Apps.radio.tabs.radio') }} >
</k-segmented-button> <SkyNavbar :title="phone.t('Apps.radio.name')" />
<k-segmented-button
:active="tab === 'settings'"
@click="tab = 'settings'"
>
<Settings :size="17" />
{{ phone.t('Apps.radio.tabs.settings') }}
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading"> <div class="radio-tab-switcher">
<k-preloader /> <SkySegmented class="radio-tabs" :aria-label="phone.t('Apps.radio.name')">
{{ phone.t('Common.loading') }} <SkySegmentedButton :active="tab === 'radio'" @click="tab = 'radio'">
<RadioTower :size="16" aria-hidden="true" />
{{ phone.t('Apps.radio.tabs.radio') }}
</SkySegmentedButton>
<SkySegmentedButton
:active="tab === 'settings'"
@click="tab = 'settings'"
>
<Settings :size="16" aria-hidden="true" />
{{ phone.t('Apps.radio.tabs.settings') }}
</SkySegmentedButton>
</SkySegmented>
</div> </div>
<template v-else-if="tab === 'radio'"> <SkyScrollArea class="radio-content">
<k-block class="radio-status" strong inset> <div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
<Signal :size="22" :class="{ 'radio-online': radio.data.connected }" /> <SkySpinner :label="phone.t('Common.loading')" />
<div> <span>{{ phone.t('Common.loading') }}</span>
<strong>{{ statusText }}</strong> </div>
<small>{{
radio.data.provider ?? phone.t('Apps.radio.noProvider')
}}</small>
</div>
<i :class="{ 'radio-status-dot--online': radio.data.connected }"></i>
</k-block>
<k-block-title>{{ phone.t('Apps.radio.channel') }}</k-block-title> <template v-else-if="tab === 'radio'">
<k-list strong inset> <SkyStatusCard
<k-list-input :title="statusText"
type="number" :subtitle="providerText"
:label="phone.t('Apps.radio.primaryFrequency')" :tone="radio.data.connected ? 'success' : 'neutral'"
:placeholder="phone.t('Apps.radio.frequencyPlaceholder')" aria-live="polite"
:min="radio.data.frequencyMin" indicator
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
:value="primaryInput"
@input="primaryInput = eventValue($event)"
> >
<template #after>{{ phone.t('Apps.radio.mhz') }}</template> <template #icon>
</k-list-input> <Signal :size="22" aria-hidden="true" />
<k-list-input </template>
v-if="radio.data.secondarySupported" </SkyStatusCard>
type="number"
:label="phone.t('Apps.radio.secondaryFrequency')" <SkySection :title="phone.t('Apps.radio.channel')">
:placeholder="phone.t('Apps.radio.optional')" <SkyList density="compact" flush inset strong>
:min="radio.data.frequencyMin" <SkyField
:max="radio.data.frequencyMax" v-model="primaryInput"
:step="radio.data.frequencyStep" type="number"
:value="secondaryInput" input-mode="decimal"
@input="secondaryInput = eventValue($event)" :label="phone.t('Apps.radio.primaryFrequency')"
> :placeholder="phone.t('Apps.radio.frequencyPlaceholder')"
<template #after>{{ phone.t('Apps.radio.mhz') }}</template> :min="radio.data.frequencyMin"
</k-list-input> :max="radio.data.frequencyMax"
<k-list-item :title="phone.t('Apps.radio.volume')"> :step="radio.data.frequencyStep"
<template #media><Volume2 :size="20" /></template> >
<template #inner> <template #leading>
<div class="radio-volume"> <RadioTower :size="20" aria-hidden="true" />
<k-range </template>
:value="radio.data.volume" <template #trailing>
<span class="radio-unit">{{ phone.t('Apps.radio.mhz') }}</span>
</template>
</SkyField>
<SkyField
v-if="radio.data.secondarySupported"
v-model="secondaryInput"
type="number"
input-mode="decimal"
:label="phone.t('Apps.radio.secondaryFrequency')"
:placeholder="phone.t('Apps.radio.optional')"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
>
<template #leading>
<RadioTower :size="20" aria-hidden="true" />
</template>
<template #trailing>
<span class="radio-unit">{{ phone.t('Apps.radio.mhz') }}</span>
</template>
</SkyField>
<SkyListItem>
<template #media>
<Volume2 :size="20" aria-hidden="true" />
</template>
<SkyRange
id="radio-volume"
v-model="volumeInput"
:aria-label="phone.t('Apps.radio.volume')"
:aria-value-text="`${volumeInput}%`"
:caption="phone.t('Apps.radio.volume')"
:min="0" :min="0"
:max="100" :max="100"
:step="1" :step="1"
:aria-label="phone.t('Apps.radio.volume')" @change="saveVolume"
@input="radio.setVolume(Number(eventValue($event)))" >
/> <output for="radio-volume">{{ volumeInput }}%</output>
<span>{{ radio.data.volume }}%</span> </SkyRange>
</div> </SkyListItem>
</template> </SkyList>
</k-list-item>
</k-list>
<k-block inset class="radio-action-block"> <div class="radio-primary-action">
<k-button <SkyButton
v-if="!radio.data.connected" v-if="!radio.data.connected"
large block
rounded large
:disabled="radio.isLoading" :disabled="radio.isLoading"
@click="connect()" @click="connect()"
> >
{{ phone.t('Apps.radio.connect') }} {{ phone.t('Apps.radio.connect') }}
</k-button> </SkyButton>
<k-button <SkyButton
v-else v-else
large block
rounded large
class="radio-disconnect" variant="danger"
@click="disconnect" :disabled="radio.isLoading"
> @click="disconnect"
{{ phone.t('Apps.radio.disconnect') }} >
</k-button> {{ phone.t('Apps.radio.disconnect') }}
<p v-if="radio.error" class="radio-error"> </SkyButton>
{{ errorText(radio.error) }} <p v-if="radio.error" class="radio-error" role="alert">
</p> {{ errorText(radio.error) }}
</k-block> </p>
</div>
</SkySection>
<template v-if="radio.data.connected"> <SkySection
<k-block-title> v-if="radio.data.connected"
<Users :size="16" /> :title="
{{
phone.t('Apps.radio.members', { phone.t('Apps.radio.members', {
count: String(radio.data.members.length), count: String(radio.data.members.length),
}) })
}} "
</k-block-title> >
<k-list strong inset> <SkyEmptyState
<k-list-item
v-for="member in radio.data.members"
:key="member.id"
:title="member.name"
:subtitle="formatDuration(member.joinTime)"
:after="member.rank || String(member.rankNumber || '')"
/>
<k-list-item
v-if="!radio.data.members.length" v-if="!radio.data.members.length"
compact
:title="phone.t('Apps.radio.noMembers')" :title="phone.t('Apps.radio.noMembers')"
/> >
</k-list> <template #icon>
<Users :size="32" aria-hidden="true" />
</template>
</SkyEmptyState>
<SkyList v-else inset strong>
<SkyListItem
v-for="member in radio.data.members"
:key="member.id"
:title="member.name"
:subtitle="formatDuration(member.joinTime)"
:after="member.rank || String(member.rankNumber || '')"
/>
</SkyList>
</SkySection>
<SkySection v-else :title="phone.t('Apps.radio.history')">
<SkyEmptyState
v-if="!radio.data.history.length"
compact
:title="phone.t('Apps.radio.noHistory')"
>
<template #icon>
<Clock3 :size="32" aria-hidden="true" />
</template>
</SkyEmptyState>
<SkyList v-else inset strong>
<SkyListItem
v-for="entry in radio.data.history"
:key="`${entry.primary}-${entry.secondary}`"
link
:title="`${entry.primary}${entry.secondary ? ` / ${entry.secondary}` : ''} ${phone.t('Apps.radio.mhz')}`"
@click="connectHistory(entry)"
/>
</SkyList>
</SkySection>
</template> </template>
<template v-else> <template v-else>
<k-block-title> <SkySettingsGroup
<Clock3 :size="16" /> v-if="radio.data.displayNameEnabled"
{{ phone.t('Apps.radio.history') }} :title="phone.t('Apps.radio.displayName')"
</k-block-title> :footer="
<k-list strong inset> phone.t(
<k-list-item radio.data.displayNameAllowed
v-for="entry in radio.data.history" ? 'Apps.radio.displayNameDescription'
:key="`${entry.primary}-${entry.secondary}`" : 'Apps.radio.displayNameNotAllowed',
link )
:title="`${entry.primary}${entry.secondary ? ` / ${entry.secondary}` : ''} ${phone.t('Apps.radio.mhz')}`" "
@click="connectHistory(entry)" >
/> <SkyField
<k-list-item v-model="displayNameInput"
v-if="!radio.data.history.length"
:title="phone.t('Apps.radio.noHistory')"
/>
</k-list>
</template>
</template>
<template v-else>
<template v-if="radio.data.displayNameEnabled">
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.displayName') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-input
type="text" type="text"
:disabled="!radio.data.displayNameAllowed" :aria-label="phone.t('Apps.radio.displayName')"
:readonly="!radio.data.displayNameAllowed"
:maxlength="radio.data.displayNameMaxLength" :maxlength="radio.data.displayNameMaxLength"
:placeholder="phone.t('Apps.radio.displayNamePlaceholder')" :placeholder="phone.t('Apps.radio.displayNamePlaceholder')"
:value="displayNameInput" @change="saveDisplayNameSetting"
@input="displayNameInput = eventValue($event)"
/> />
</k-list> </SkySettingsGroup>
<k-block class="radio-hint-block">
<p class="radio-setting-hint">
{{
phone.t(
radio.data.displayNameAllowed
? 'Apps.radio.displayNameDescription'
: 'Apps.radio.displayNameNotAllowed',
)
}}
</p>
</k-block>
</template>
<template v-if="radio.data.badgeEnabled"> <SkySettingsGroup
<k-block-title class="radio-settings-title"> v-if="radio.data.badgeEnabled"
{{ phone.t('Apps.radio.badge') }} :title="phone.t('Apps.radio.badge')"
</k-block-title> >
<k-list strong inset class="radio-settings-list"> <SkyField
<k-list-input v-model="badgeInput"
type="text" type="text"
:aria-label="phone.t('Apps.radio.badge')"
:maxlength="radio.data.badgeMaxLength" :maxlength="radio.data.badgeMaxLength"
:placeholder="phone.t('Apps.radio.badgePlaceholder')" :placeholder="phone.t('Apps.radio.badgePlaceholder')"
:value="badgeInput" @input="badgeInput = badgeInput.replace(/[^A-Za-z0-9_-]/g, '')"
@input=" @change="saveBadgeSetting"
badgeInput = eventValue($event).replace(/[^A-Za-z0-9_-]/g, '')
"
/> />
</k-list> </SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.radio.otherSettings')">
<SkySettingsRow
kind="toggle"
:model-value="radio.data.settings.autoRejoin"
:title="phone.t('Apps.radio.autoRejoin')"
:description="phone.t('Apps.radio.autoRejoinDescription')"
@update:model-value="radio.saveSetting('autoRejoin', $event)"
/>
<SkySettingsRow
kind="toggle"
:model-value="radio.data.settings.notifications"
:title="phone.t('Apps.radio.radioNotifications')"
:description="phone.t('Apps.radio.notificationsDescription')"
@update:model-value="radio.saveSetting('notifications', $event)"
/>
</SkySettingsGroup>
</template> </template>
</SkyScrollArea>
<k-block <SkyToast
v-if="
radio.data.badgeEnabled ||
(radio.data.displayNameEnabled && radio.data.displayNameAllowed)
"
inset
class="radio-action-block radio-profile-action"
>
<k-button rounded large @click="saveRadioProfile">
{{ phone.t('Common.save') }}
</k-button>
</k-block>
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.otherSettings') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.autoRejoin')"
:subtitle="phone.t('Apps.radio.autoRejoinDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.autoRejoin"
@change="
radio.saveSetting('autoRejoin', !radio.data.settings.autoRejoin)
"
/>
</template>
</k-list-item>
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.radioNotifications')"
:subtitle="phone.t('Apps.radio.notificationsDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.notifications"
@change="
radio.saveSetting(
'notifications',
!radio.data.settings.notifications,
)
"
/>
</template>
</k-list-item>
</k-list>
</template>
<k-toast
:opened="Boolean(feedback)" :opened="Boolean(feedback)"
position="center" position="center"
@click="feedback = ''" @click="feedback = ''"
> >
{{ feedback }} {{ feedback }}
</k-toast> </SkyToast>
</k-page> </SkyAppPage>
</template> </template>
<style scoped> <style scoped>
.radio-app { .radio-tab-switcher {
--radio-blue: #0a84ff; padding: 4px var(--sky-page-gutter) 8px;
overflow-y: auto; flex: none;
padding-bottom: 34px;
} }
.radio-tabs :deep(button) { .radio-tabs :deep(.sky-segmented-button) {
align-items: center;
display: flex;
gap: 6px; gap: 6px;
justify-content: center; }
.radio-content {
padding-top: 4px;
} }
.radio-loading { .radio-loading {
align-items: center;
display: flex;
gap: 10px;
justify-content: center;
min-height: 240px; min-height: 240px;
}
.radio-status {
align-items: center;
display: grid;
gap: 12px;
grid-template-columns: auto 1fr auto;
margin-top: 18px;
}
.radio-status div {
display: flex; display: flex;
flex-direction: column;
min-width: 0;
}
.radio-status small {
color: var(--k-color-subtitle, #8e8e93);
margin-top: 2px;
text-transform: capitalize;
}
.radio-status i {
background: #c7c7cc;
border-radius: 50%;
height: 10px;
width: 10px;
}
.radio-status .radio-status-dot--online {
background: #30d158;
}
.radio-online {
color: #30d158;
}
.radio-volume {
align-items: center; align-items: center;
display: grid; justify-content: center;
gap: 12px; gap: 10px;
grid-template-columns: 1fr 44px; color: var(--sky-muted);
width: 100%; font-size: 14px;
} }
.radio-volume span { .radio-unit {
color: var(--k-color-subtitle, #8e8e93); color: var(--sky-muted);
font-variant-numeric: tabular-nums; font-size: 12px;
text-align: right; font-weight: 600;
} }
.radio-action-block { .radio-primary-action {
padding-left: 0;
padding-right: 0;
}
.radio-setting-hint {
color: inherit;
font-size: 16px;
font-weight: 450;
line-height: 1.45;
margin: 0;
opacity: 0.82;
}
.radio-hint-block {
margin-bottom: 0;
margin-top: 8px;
}
.radio-settings-title {
margin-bottom: 6px;
margin-top: 16px;
}
.radio-settings-list {
margin-bottom: 0;
margin-top: 0;
}
.radio-profile-action {
margin-bottom: 0;
margin-top: 12px; margin-top: 12px;
} }
.radio-setting-row :deep(.text-sm) {
font-size: 15px;
line-height: 1.35;
}
.radio-error { .radio-error {
color: #ff3b30; margin: 9px 4px 0;
font-size: 13px; color: var(--sky-danger);
margin: 10px 4px 0; font-size: 12px;
line-height: 16px;
text-align: center; text-align: center;
} }
.radio-disconnect {
background: #ff3b30 !important;
color: #fff !important;
}
:deep(.k-block-title) {
align-items: center;
display: flex;
gap: 6px;
}
</style> </style>