mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +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 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'
|
||||||
|
|||||||
@@ -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 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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user