Files
sky_phone/frontend/src/views/apps/RadioApp.vue
T
2026-08-13 03:10:19 +02:00

508 lines
15 KiB
Vue

<script setup lang="ts">
import {
Clock3,
RadioTower,
Settings,
Signal,
Users,
Volume2,
} from 'lucide-vue-next'
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'
const phone = usePhoneStore()
const radio = useRadioStore()
const tab = ref<RadioTab>('radio')
const primaryInput = ref('')
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')
const secondary = radio.data.secondaryFrequency
? ` / ${radio.data.secondaryFrequency}`
: ''
return phone.t('Apps.radio.connectedTo', {
frequency: `${radio.data.frequency}${secondary}`,
})
})
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(',', '.'))
}
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,
): Promise<void> {
if (!Number.isFinite(primary)) {
radio.error = 'invalid_frequency'
return
}
const connected = await radio.connect(primary, secondary)
if (connected) {
memberSnapshotAt.value = Date.now()
primaryInput.value = String(radio.data.frequency)
secondaryInput.value = radio.data.secondaryFrequency
? String(radio.data.secondaryFrequency)
: ''
}
}
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) : ''
void connect(entry.primary, entry.secondary)
}
function formatDuration(joinTime: number): string {
const seconds = Math.max(
0,
joinTime + Math.floor((now.value - memberSnapshotAt.value) / 1000),
)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours) return `${hours}h ${minutes % 60}m`
if (minutes) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
function normalizedBadge(): string {
const clean = badgeInput.value
.replace(/[^A-Za-z0-9_-]/g, '')
.slice(0, radio.data.badgeMaxLength)
badgeInput.value = clean
return clean
}
function normalizedDisplayName(): string {
const clean = Array.from(
displayNameInput.value
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim(),
)
.slice(0, radio.data.displayNameMaxLength)
.join('')
displayNameInput.value = clean
return clean
}
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
}
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
}
badgeInput.value = radio.data.badge
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'radio:updated' && event.data.data?.members) {
memberSnapshotAt.value = Date.now()
radio.updateMembers(event.data.data.members)
}
}
watch(
() => radio.data.volume,
(volume) => (volumeInput.value = volume),
)
onMounted(async () => {
window.addEventListener('message', onMessage)
clockHandle = window.setInterval(() => (now.value = Date.now()), 1000)
await radio.load()
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) {
secondaryInput.value = String(radio.data.secondaryFrequency)
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
if (clockHandle !== null) window.clearInterval(clockHandle)
if (feedbackHandle !== null) window.clearTimeout(feedbackHandle)
})
</script>
<template>
<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 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>
<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>
<template v-else-if="tab === 'radio'">
<SkyStatusCard
:title="statusText"
:subtitle="providerText"
:tone="radio.data.connected ? 'success' : 'neutral'"
aria-live="polite"
indicator
>
<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"
@change="saveVolume"
>
<output for="radio-volume">{{ volumeInput }}%</output>
</SkyRange>
</SkyListItem>
</SkyList>
<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>
<SkySection
v-if="radio.data.connected"
:title="
phone.t('Apps.radio.members', {
count: String(radio.data.members.length),
})
"
>
<SkyEmptyState
v-if="!radio.data.members.length"
compact
:title="phone.t('Apps.radio.noMembers')"
>
<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>
<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"
:aria-label="phone.t('Apps.radio.displayName')"
:readonly="!radio.data.displayNameAllowed"
:maxlength="radio.data.displayNameMaxLength"
:placeholder="phone.t('Apps.radio.displayNamePlaceholder')"
@change="saveDisplayNameSetting"
/>
</SkySettingsGroup>
<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')"
@input="badgeInput = badgeInput.replace(/[^A-Za-z0-9_-]/g, '')"
@change="saveBadgeSetting"
/>
</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>
<SkyToast
:opened="Boolean(feedback)"
position="center"
@click="feedback = ''"
>
{{ feedback }}
</SkyToast>
</SkyAppPage>
</template>
<style scoped>
.radio-tab-switcher {
padding: 4px var(--sky-page-gutter) 8px;
flex: none;
}
.radio-tabs :deep(.sky-segmented-button) {
gap: 6px;
}
.radio-content {
padding-top: 4px;
}
.radio-loading {
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--sky-muted);
font-size: 14px;
}
.radio-unit {
color: var(--sky-muted);
font-size: 12px;
font-weight: 600;
}
.radio-primary-action {
margin-top: 12px;
}
.radio-error {
margin: 9px 4px 0;
color: var(--sky-danger);
font-size: 12px;
line-height: 16px;
text-align: center;
}
</style>