mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 19:28:56 +00:00
508 lines
15 KiB
Vue
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>
|