diff --git a/src/components/GamePage.vue b/src/components/GamePage.vue index d14d78e..fc49b3e 100644 --- a/src/components/GamePage.vue +++ b/src/components/GamePage.vue @@ -19,7 +19,7 @@ import { type UploadFileInfo, } from 'naive-ui' import { useMessage } from 'naive-ui' -import { computed, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getAuthClient } from '@/api/auth_client' @@ -58,6 +58,7 @@ import { useAuthStore } from '@/stores/auth' import { useUIStore } from '@/stores/ui' import { applicationFileType, applicationFileURL } from '@/utils/application' import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime' +import { hasNewTeamApplications } from '@/utils/teamApplications' const emit = defineEmits<{ resultsApplied: [] }>() @@ -128,6 +129,12 @@ const game = ref({ const authStore = useAuthStore() const uiStore = useUIStore() const canManageGame = computed(() => authStore.hasAnyRole(['admin', 'organizer', 'author'])) +let gameRequestVersion = 0 +let appliedGameRequestVersion = 0 +let hasLoadedGame = false +let isDisposed = false +let notificationAudio: AudioContext | undefined +let hasReportedSoundError = false const canViewState = computed( () => uiStore.environment === 'venue' && @@ -220,10 +227,80 @@ const mapDragging = ref(false) const screenVolume = ref(100) const screenPosition = ref(0) +function reportSoundError(error: unknown) { + if (isDisposed || hasReportedSoundError) return + hasReportedSoundError = true + message.warning('Не удалось включить звуковые уведомления о выдаче улик') + console.error('Звуковое уведомление о выдаче улик', error) +} + +async function enableNotificationSound() { + if (!canManageGame.value || isDisposed) return + try { + notificationAudio ??= new AudioContext() + if (notificationAudio.state === 'suspended') await notificationAudio.resume() + } catch (error) { + reportSoundError(error) + } +} + +function playApplicationNotification() { + // До первого взаимодействия браузер блокирует звук; пропущенные сигналы не накапливаем. + if (!notificationAudio || notificationAudio.state !== 'running') return + try { + const oscillator = notificationAudio.createOscillator() + const gain = notificationAudio.createGain() + const now = notificationAudio.currentTime + oscillator.frequency.value = 880 + gain.gain.setValueAtTime(0, now) + gain.gain.linearRampToValueAtTime(0.1, now + 0.02) + gain.gain.linearRampToValueAtTime(0, now + 0.3) + oscillator.connect(gain) + gain.connect(notificationAudio.destination) + oscillator.onended = () => { + oscillator.disconnect() + gain.disconnect() + } + oscillator.start(now) + oscillator.stop(now + 0.3) + } catch (error) { + reportSoundError(error) + } +} + +onMounted(() => { + window.addEventListener('click', enableNotificationSound) + window.addEventListener('keydown', enableNotificationSound) +}) +onUnmounted(() => { + isDisposed = true + window.removeEventListener('click', enableNotificationSound) + window.removeEventListener('keydown', enableNotificationSound) + void notificationAudio?.close().catch((error: unknown) => { + console.error('Закрытие звуковых уведомлений', error) + }) +}) + async function getGame(id: number) { + const requestVersion = ++gameRequestVersion const wasManaging = canManageGame.value const res = await client.GetGame({ ID: id }) - if (wasManaging !== canManageGame.value) return + if ( + isDisposed || + wasManaging !== canManageGame.value || + requestVersion < appliedGameRequestVersion + ) { + return + } + appliedGameRequestVersion = requestVersion + if ( + canManageGame.value && + hasLoadedGame && + hasNewTeamApplications(game.value.teams || [], res.game?.teams || []) + ) { + playApplicationNotification() + } + hasLoadedGame = true game.value = res.game! if (!settingsDraftInitialized.value) { settingsDraft.value = { diff --git a/src/utils/__tests__/teamApplications.test.ts b/src/utils/__tests__/teamApplications.test.ts new file mode 100644 index 0000000..8b748b0 --- /dev/null +++ b/src/utils/__tests__/teamApplications.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import type { Team } from '../../api/generated/crabs/evening_detective_server/index.ts' +import { hasNewTeamApplications } from '../teamApplications.ts' + +function team(ID: string, names: string[]): Team { + return { + ID, + name: undefined, + status: undefined, + password: undefined, + actionsCount: undefined, + applications: names.map((name) => ({ name, image: undefined, file_type: undefined })), + captainEmail: undefined, + participantsCount: undefined, + participationStatus: undefined, + participationSource: undefined, + participationEmailError: undefined, + participationEmailSent: undefined, + } +} + +for (const scenario of [ + { name: 'empty lists', previous: [], current: [], expected: false }, + { + name: 'new application', + previous: [team('a', [])], + current: [team('a', ['clue'])], + expected: true, + }, + { + name: 'unchanged applications and reordered teams', + previous: [team('a', ['one', 'two']), team('b', ['clue'])], + current: [team('b', ['clue']), team('a', ['two', 'one'])], + expected: false, + }, + { + name: 'issued application', + previous: [team('a', ['clue'])], + current: [team('a', [])], + expected: false, + }, + { + name: 'replacement with unchanged count', + previous: [team('a', ['one'])], + current: [team('a', ['two'])], + expected: true, + }, + { + name: 'same application for another team', + previous: [team('a', ['clue']), team('b', [])], + current: [team('a', ['clue']), team('b', ['clue'])], + expected: true, + }, + { + name: 'multiple new applications', + previous: [], + current: [team('a', ['one', 'two']), team('b', ['clue'])], + expected: true, + }, + { + name: 'team and application names cannot collide', + previous: [team('a:b', ['c'])], + current: [team('a', ['b:c'])], + expected: true, + }, +]) { + test(scenario.name, () => { + assert.equal(hasNewTeamApplications(scenario.previous, scenario.current), scenario.expected) + }) +} diff --git a/src/utils/teamApplications.ts b/src/utils/teamApplications.ts new file mode 100644 index 0000000..248d8de --- /dev/null +++ b/src/utils/teamApplications.ts @@ -0,0 +1,14 @@ +import type { Team } from '@/api/generated/crabs/evening_detective_server' + +export function hasNewTeamApplications(previous: Team[], current: Team[]): boolean { + const previousKeys = new Set( + previous.flatMap((team) => + (team.applications || []).map((application) => JSON.stringify([team.ID, application.name])), + ), + ) + return current.some((team) => + team.applications?.some( + (application) => !previousKeys.has(JSON.stringify([team.ID, application.name])), + ), + ) +}