mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - migrate Radio to Sky UI
This commit is contained in:
@@ -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 |
@@ -47,7 +47,7 @@ import mapIcon from '@/assets/img/app-icons/map.webp'
|
||||
import messagesIcon from '@/assets/img/app-icons/sms.webp'
|
||||
import darkChatIcon from '@/assets/img/app-icons/darkchat.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 phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
import settingsIcon from '@/assets/img/app-icons/settings.svg'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,13 @@ export const useRadioStore = defineStore('radio', () => {
|
||||
const data = reactive<RadioData>(structuredClone(defaults))
|
||||
const error = ref('')
|
||||
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 {
|
||||
Object.assign(data, next)
|
||||
@@ -61,10 +68,12 @@ export const useRadioStore = defineStore('radio', () => {
|
||||
}
|
||||
|
||||
async function disconnect(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
const response = await nuiCall('radio:disconnect')
|
||||
if (!response.success) {
|
||||
error.value = response.error ?? 'request_failed'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
apply({
|
||||
@@ -73,13 +82,16 @@ export const useRadioStore = defineStore('radio', () => {
|
||||
members: [],
|
||||
secondaryFrequency: 0,
|
||||
})
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
async function setVolume(volume: number): Promise<void> {
|
||||
const requestId = ++volumeRequestId
|
||||
data.volume = volume
|
||||
const response = await nuiCall<{ volume: number }>('radio:set-volume', {
|
||||
volume,
|
||||
})
|
||||
if (requestId !== volumeRequestId) return
|
||||
if (response.success && response.data) data.volume = response.data.volume
|
||||
}
|
||||
|
||||
@@ -87,35 +99,42 @@ export const useRadioStore = defineStore('radio', () => {
|
||||
key: keyof RadioSettings,
|
||||
value: boolean,
|
||||
): Promise<void> {
|
||||
const requestId = ++settingRequestIds[key]
|
||||
const previous = data.settings[key]
|
||||
data.settings[key] = value
|
||||
const response = await nuiCall<RadioSettings>('radio:save-settings', {
|
||||
key,
|
||||
value,
|
||||
})
|
||||
if (response.success && response.data) data.settings = response.data
|
||||
else {
|
||||
if (requestId !== settingRequestIds[key]) return
|
||||
if (response.success && response.data) {
|
||||
data.settings[key] = response.data[key]
|
||||
} else {
|
||||
data.settings[key] = previous
|
||||
error.value = response.error ?? 'request_failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBadge(badge: string): Promise<boolean> {
|
||||
const requestId = ++badgeRequestId
|
||||
error.value = ''
|
||||
const response = await nuiCall<{ badge: string }>('radio:save-badge', {
|
||||
badge,
|
||||
})
|
||||
if (requestId !== badgeRequestId) return true
|
||||
if (response.success && response.data) data.badge = response.data.badge
|
||||
else error.value = response.error ?? 'request_failed'
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function saveDisplayName(displayName: string): Promise<boolean> {
|
||||
const requestId = ++displayNameRequestId
|
||||
error.value = ''
|
||||
const response = await nuiCall<{ displayName: string }>(
|
||||
'radio:save-display-name',
|
||||
{ displayName },
|
||||
)
|
||||
if (requestId !== displayNameRequestId) return true
|
||||
if (response.success && response.data)
|
||||
data.displayName = response.data.displayName
|
||||
else error.value = response.error ?? 'request_failed'
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kBlock,
|
||||
kBlockTitle,
|
||||
kButton,
|
||||
kList,
|
||||
kListInput,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kPreloader,
|
||||
kRange,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kToast,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
Clock3,
|
||||
RadioTower,
|
||||
@@ -23,11 +7,30 @@ import {
|
||||
Users,
|
||||
Volume2,
|
||||
} 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 { useRadioStore } from '@/stores/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'
|
||||
|
||||
type RadioTab = 'radio' | 'settings'
|
||||
@@ -40,9 +43,11 @@ const secondaryInput = ref('')
|
||||
const badgeInput = ref('')
|
||||
const displayNameInput = ref('')
|
||||
const feedback = ref('')
|
||||
const volumeInput = ref(50)
|
||||
const now = ref(Date.now())
|
||||
const memberSnapshotAt = ref(Date.now())
|
||||
let clockHandle: number | null = null
|
||||
let feedbackHandle: number | null = null
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!radio.data.connected) return phone.t('Apps.radio.disconnected')
|
||||
@@ -54,9 +59,11 @@ const statusText = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function eventValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
const providerText = computed(() => {
|
||||
const provider = radio.data.provider
|
||||
if (!provider) return phone.t('Apps.radio.noProvider')
|
||||
return provider.replace(/^./, (character) => character.toLocaleUpperCase())
|
||||
})
|
||||
|
||||
function parseFrequency(value: string): number {
|
||||
return Number.parseFloat(value.replace(',', '.'))
|
||||
@@ -66,6 +73,15 @@ function errorText(code: string): string {
|
||||
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(
|
||||
primary = parseFrequency(primaryInput.value),
|
||||
secondary = parseFrequency(secondaryInput.value) || 0,
|
||||
@@ -74,6 +90,7 @@ async function connect(
|
||||
radio.error = 'invalid_frequency'
|
||||
return
|
||||
}
|
||||
|
||||
const connected = await radio.connect(primary, secondary)
|
||||
if (connected) {
|
||||
memberSnapshotAt.value = Date.now()
|
||||
@@ -88,6 +105,10 @@ async function disconnect(): Promise<void> {
|
||||
await radio.disconnect()
|
||||
}
|
||||
|
||||
function saveVolume(): void {
|
||||
void radio.setVolume(volumeInput.value)
|
||||
}
|
||||
|
||||
function connectHistory(entry: RadioHistoryEntry): void {
|
||||
primaryInput.value = String(entry.primary)
|
||||
secondaryInput.value = entry.secondary ? String(entry.secondary) : ''
|
||||
@@ -127,29 +148,40 @@ function normalizedDisplayName(): string {
|
||||
return clean
|
||||
}
|
||||
|
||||
async function saveRadioProfile(): Promise<void> {
|
||||
if (radio.data.displayNameEnabled && radio.data.displayNameAllowed) {
|
||||
const displayNameSaved = await radio.saveDisplayName(
|
||||
normalizedDisplayName(),
|
||||
)
|
||||
if (!displayNameSaved) {
|
||||
feedback.value = errorText(radio.error)
|
||||
window.setTimeout(() => (feedback.value = ''), 2500)
|
||||
return
|
||||
}
|
||||
async function saveDisplayNameSetting(): Promise<void> {
|
||||
if (!radio.data.displayNameEnabled || !radio.data.displayNameAllowed) return
|
||||
|
||||
const displayName = normalizedDisplayName()
|
||||
if (displayName === radio.data.displayName) return
|
||||
|
||||
const saved = await radio.saveDisplayName(displayName)
|
||||
if (displayNameInput.value !== displayName) return
|
||||
|
||||
if (!saved) {
|
||||
displayNameInput.value = radio.data.displayName
|
||||
showFeedback(errorText(radio.error))
|
||||
return
|
||||
}
|
||||
|
||||
if (radio.data.badgeEnabled) {
|
||||
const badgeSaved = await radio.saveBadge(normalizedBadge())
|
||||
if (!badgeSaved) {
|
||||
feedback.value = errorText(radio.error)
|
||||
window.setTimeout(() => (feedback.value = ''), 2500)
|
||||
return
|
||||
}
|
||||
displayNameInput.value = radio.data.displayName
|
||||
}
|
||||
|
||||
async function saveBadgeSetting(): Promise<void> {
|
||||
if (!radio.data.badgeEnabled) 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')
|
||||
window.setTimeout(() => (feedback.value = ''), 2500)
|
||||
badgeInput.value = radio.data.badge
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
@@ -160,6 +192,11 @@ function onMessage(event: MessageEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => radio.data.volume,
|
||||
(volume) => (volumeInput.value = volume),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('message', onMessage)
|
||||
clockHandle = window.setInterval(() => (now.value = Date.now()), 1000)
|
||||
@@ -167,399 +204,304 @@ onMounted(async () => {
|
||||
memberSnapshotAt.value = Date.now()
|
||||
badgeInput.value = radio.data.badge
|
||||
displayNameInput.value = radio.data.displayName
|
||||
volumeInput.value = radio.data.volume
|
||||
if (radio.data.frequency) primaryInput.value = String(radio.data.frequency)
|
||||
if (radio.data.secondaryFrequency)
|
||||
if (radio.data.secondaryFrequency) {
|
||||
secondaryInput.value = String(radio.data.secondaryFrequency)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (clockHandle) window.clearInterval(clockHandle)
|
||||
if (clockHandle !== null) window.clearInterval(clockHandle)
|
||||
if (feedbackHandle !== null) window.clearTimeout(feedbackHandle)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page component="main" class="radio-app">
|
||||
<k-navbar :title="phone.t('Apps.radio.name')">
|
||||
<template #subnavbar>
|
||||
<k-segmented :key="tab" strong rounded class="radio-tabs">
|
||||
<k-segmented-button :active="tab === 'radio'" @click="tab = 'radio'">
|
||||
<RadioTower :size="17" />
|
||||
{{ phone.t('Apps.radio.tabs.radio') }}
|
||||
</k-segmented-button>
|
||||
<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>
|
||||
<SkyAppPage
|
||||
class="radio-app"
|
||||
accent="#0a84ff"
|
||||
accent-soft="rgba(10, 132, 255, 0.16)"
|
||||
:dark="phone.isDarkMode"
|
||||
:label="phone.t('Apps.radio.name')"
|
||||
>
|
||||
<SkyNavbar :title="phone.t('Apps.radio.name')" />
|
||||
|
||||
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
|
||||
<k-preloader />
|
||||
{{ phone.t('Common.loading') }}
|
||||
<div class="radio-tab-switcher">
|
||||
<SkySegmented class="radio-tabs" :aria-label="phone.t('Apps.radio.name')">
|
||||
<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>
|
||||
|
||||
<template v-else-if="tab === 'radio'">
|
||||
<k-block class="radio-status" strong inset>
|
||||
<Signal :size="22" :class="{ 'radio-online': radio.data.connected }" />
|
||||
<div>
|
||||
<strong>{{ statusText }}</strong>
|
||||
<small>{{
|
||||
radio.data.provider ?? phone.t('Apps.radio.noProvider')
|
||||
}}</small>
|
||||
</div>
|
||||
<i :class="{ 'radio-status-dot--online': radio.data.connected }"></i>
|
||||
</k-block>
|
||||
<SkyScrollArea class="radio-content">
|
||||
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
|
||||
<SkySpinner :label="phone.t('Common.loading')" />
|
||||
<span>{{ phone.t('Common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<k-block-title>{{ phone.t('Apps.radio.channel') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-input
|
||||
type="number"
|
||||
:label="phone.t('Apps.radio.primaryFrequency')"
|
||||
:placeholder="phone.t('Apps.radio.frequencyPlaceholder')"
|
||||
:min="radio.data.frequencyMin"
|
||||
:max="radio.data.frequencyMax"
|
||||
:step="radio.data.frequencyStep"
|
||||
:value="primaryInput"
|
||||
@input="primaryInput = eventValue($event)"
|
||||
<template v-else-if="tab === 'radio'">
|
||||
<SkyStatusCard
|
||||
:title="statusText"
|
||||
:subtitle="providerText"
|
||||
:tone="radio.data.connected ? 'success' : 'neutral'"
|
||||
aria-live="polite"
|
||||
indicator
|
||||
>
|
||||
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
|
||||
</k-list-input>
|
||||
<k-list-input
|
||||
v-if="radio.data.secondarySupported"
|
||||
type="number"
|
||||
:label="phone.t('Apps.radio.secondaryFrequency')"
|
||||
:placeholder="phone.t('Apps.radio.optional')"
|
||||
:min="radio.data.frequencyMin"
|
||||
:max="radio.data.frequencyMax"
|
||||
:step="radio.data.frequencyStep"
|
||||
:value="secondaryInput"
|
||||
@input="secondaryInput = eventValue($event)"
|
||||
>
|
||||
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
|
||||
</k-list-input>
|
||||
<k-list-item :title="phone.t('Apps.radio.volume')">
|
||||
<template #media><Volume2 :size="20" /></template>
|
||||
<template #inner>
|
||||
<div class="radio-volume">
|
||||
<k-range
|
||||
:value="radio.data.volume"
|
||||
<template #icon>
|
||||
<Signal :size="22" aria-hidden="true" />
|
||||
</template>
|
||||
</SkyStatusCard>
|
||||
|
||||
<SkySection :title="phone.t('Apps.radio.channel')">
|
||||
<SkyList density="compact" flush inset strong>
|
||||
<SkyField
|
||||
v-model="primaryInput"
|
||||
type="number"
|
||||
input-mode="decimal"
|
||||
:label="phone.t('Apps.radio.primaryFrequency')"
|
||||
:placeholder="phone.t('Apps.radio.frequencyPlaceholder')"
|
||||
: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>
|
||||
<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"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:aria-label="phone.t('Apps.radio.volume')"
|
||||
@input="radio.setVolume(Number(eventValue($event)))"
|
||||
/>
|
||||
<span>{{ radio.data.volume }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
@change="saveVolume"
|
||||
>
|
||||
<output for="radio-volume">{{ volumeInput }}%</output>
|
||||
</SkyRange>
|
||||
</SkyListItem>
|
||||
</SkyList>
|
||||
|
||||
<k-block inset class="radio-action-block">
|
||||
<k-button
|
||||
v-if="!radio.data.connected"
|
||||
large
|
||||
rounded
|
||||
:disabled="radio.isLoading"
|
||||
@click="connect()"
|
||||
>
|
||||
{{ phone.t('Apps.radio.connect') }}
|
||||
</k-button>
|
||||
<k-button
|
||||
v-else
|
||||
large
|
||||
rounded
|
||||
class="radio-disconnect"
|
||||
@click="disconnect"
|
||||
>
|
||||
{{ phone.t('Apps.radio.disconnect') }}
|
||||
</k-button>
|
||||
<p v-if="radio.error" class="radio-error">
|
||||
{{ errorText(radio.error) }}
|
||||
</p>
|
||||
</k-block>
|
||||
<div class="radio-primary-action">
|
||||
<SkyButton
|
||||
v-if="!radio.data.connected"
|
||||
block
|
||||
large
|
||||
:disabled="radio.isLoading"
|
||||
@click="connect()"
|
||||
>
|
||||
{{ phone.t('Apps.radio.connect') }}
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
v-else
|
||||
block
|
||||
large
|
||||
variant="danger"
|
||||
:disabled="radio.isLoading"
|
||||
@click="disconnect"
|
||||
>
|
||||
{{ phone.t('Apps.radio.disconnect') }}
|
||||
</SkyButton>
|
||||
<p v-if="radio.error" class="radio-error" role="alert">
|
||||
{{ errorText(radio.error) }}
|
||||
</p>
|
||||
</div>
|
||||
</SkySection>
|
||||
|
||||
<template v-if="radio.data.connected">
|
||||
<k-block-title>
|
||||
<Users :size="16" />
|
||||
{{
|
||||
<SkySection
|
||||
v-if="radio.data.connected"
|
||||
:title="
|
||||
phone.t('Apps.radio.members', {
|
||||
count: String(radio.data.members.length),
|
||||
})
|
||||
}}
|
||||
</k-block-title>
|
||||
<k-list strong inset>
|
||||
<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
|
||||
"
|
||||
>
|
||||
<SkyEmptyState
|
||||
v-if="!radio.data.members.length"
|
||||
compact
|
||||
: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 v-else>
|
||||
<k-block-title>
|
||||
<Clock3 :size="16" />
|
||||
{{ phone.t('Apps.radio.history') }}
|
||||
</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
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)"
|
||||
/>
|
||||
<k-list-item
|
||||
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
|
||||
<SkySettingsGroup
|
||||
v-if="radio.data.displayNameEnabled"
|
||||
:title="phone.t('Apps.radio.displayName')"
|
||||
:footer="
|
||||
phone.t(
|
||||
radio.data.displayNameAllowed
|
||||
? 'Apps.radio.displayNameDescription'
|
||||
: 'Apps.radio.displayNameNotAllowed',
|
||||
)
|
||||
"
|
||||
>
|
||||
<SkyField
|
||||
v-model="displayNameInput"
|
||||
type="text"
|
||||
:disabled="!radio.data.displayNameAllowed"
|
||||
:aria-label="phone.t('Apps.radio.displayName')"
|
||||
:readonly="!radio.data.displayNameAllowed"
|
||||
:maxlength="radio.data.displayNameMaxLength"
|
||||
:placeholder="phone.t('Apps.radio.displayNamePlaceholder')"
|
||||
:value="displayNameInput"
|
||||
@input="displayNameInput = eventValue($event)"
|
||||
@change="saveDisplayNameSetting"
|
||||
/>
|
||||
</k-list>
|
||||
<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>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<template v-if="radio.data.badgeEnabled">
|
||||
<k-block-title class="radio-settings-title">
|
||||
{{ phone.t('Apps.radio.badge') }}
|
||||
</k-block-title>
|
||||
<k-list strong inset class="radio-settings-list">
|
||||
<k-list-input
|
||||
<SkySettingsGroup
|
||||
v-if="radio.data.badgeEnabled"
|
||||
:title="phone.t('Apps.radio.badge')"
|
||||
>
|
||||
<SkyField
|
||||
v-model="badgeInput"
|
||||
type="text"
|
||||
:aria-label="phone.t('Apps.radio.badge')"
|
||||
:maxlength="radio.data.badgeMaxLength"
|
||||
:placeholder="phone.t('Apps.radio.badgePlaceholder')"
|
||||
:value="badgeInput"
|
||||
@input="
|
||||
badgeInput = eventValue($event).replace(/[^A-Za-z0-9_-]/g, '')
|
||||
"
|
||||
@input="badgeInput = badgeInput.replace(/[^A-Za-z0-9_-]/g, '')"
|
||||
@change="saveBadgeSetting"
|
||||
/>
|
||||
</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>
|
||||
</SkyScrollArea>
|
||||
|
||||
<k-block
|
||||
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
|
||||
<SkyToast
|
||||
:opened="Boolean(feedback)"
|
||||
position="center"
|
||||
@click="feedback = ''"
|
||||
>
|
||||
{{ feedback }}
|
||||
</k-toast>
|
||||
</k-page>
|
||||
</SkyToast>
|
||||
</SkyAppPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.radio-app {
|
||||
--radio-blue: #0a84ff;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 34px;
|
||||
.radio-tab-switcher {
|
||||
padding: 4px var(--sky-page-gutter) 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.radio-tabs :deep(button) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
.radio-tabs :deep(.sky-segmented-button) {
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-content {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.radio-loading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
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;
|
||||
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;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr 44px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--sky-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.radio-volume span {
|
||||
color: var(--k-color-subtitle, #8e8e93);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
.radio-unit {
|
||||
color: var(--sky-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.radio-action-block {
|
||||
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;
|
||||
.radio-primary-action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.radio-setting-row :deep(.text-sm) {
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.radio-error {
|
||||
color: #ff3b30;
|
||||
font-size: 13px;
|
||||
margin: 10px 4px 0;
|
||||
margin: 9px 4px 0;
|
||||
color: var(--sky-danger);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.radio-disconnect {
|
||||
background: #ff3b30 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.k-block-title) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user