This commit is contained in:
2026-10-17 18:14:31 +07:00
parent 1c42ae2a75
commit 701bca234e
31 changed files with 9774 additions and 21 deletions
+300
View File
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Action, HostInfo, MediaKind, State } from '../types/screen'
const message = useMessage()
const bridge = window.screenBridge
const isPhone = !bridge
const state = ref<State | null>(null)
const host = ref<HostInfo>({ displays: [], lan: [], url: '', code: '', pending: [] })
const display = ref<number | null>(null)
const address = ref<string | null>(null)
const media = ref<MediaKind>('audio')
const position = ref<number | null>(10)
const code = ref('')
const pairingId = ref('')
const isConnected = ref(false)
const isBusy = ref(false)
const isApproved = ref(false)
const pairLabel = computed(() =>
pairingId.value
? `Ожидание подтверждения ${pairingId.value.slice(0, 8)} на компьютере`
: 'Подключиться',
)
const metrics = computed(() => {
return (['computer', 'phone'] as const)
.map((origin) => {
const values = (state.value?.samples ?? [])
.filter((sample) => sample.media === media.value && sample.origin === origin)
.map((sample) => sample.milliseconds)
.sort((a, b) => a - b)
const label = origin === 'computer' ? 'Компьютер' : 'Телефон'
if (!values.length) return `${label}: замеров пока нет`
const middle = Math.floor(values.length / 2)
const median =
values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
return `${label}: n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
})
.join(' · ')
})
const statusLabels: Record<State['status'], string> = {
idle: 'Ожидание',
pending: 'Команда отправлена',
playing: 'Воспроизведение подтверждено',
paused: 'Пауза',
stopped: 'Остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
let socket: WebSocket | null = null
let timer: ReturnType<typeof setInterval> | undefined
let unsubscribe: (() => void) | undefined
let isDisposed = false
async function run(action: () => Promise<unknown>) {
isBusy.value = true
try {
await action()
} catch (error) {
message.error(error instanceof Error ? error.message : 'Не удалось выполнить действие')
} finally {
isBusy.value = false
}
}
function acceptState(value: State) {
if (!state.value || value.revision >= state.value.revision) state.value = value
}
async function refreshHost() {
if (bridge) host.value = await bridge.host()
}
async function send(action: Action) {
const command = { id: cryptoId(), action, media: media.value, position: position.value ?? 0 }
await run(async () => {
if (bridge) await bridge.command(command)
else {
if (!socket || socket.readyState !== WebSocket.OPEN)
throw new Error('Связь с компьютером потеряна')
socket.send(JSON.stringify(command))
}
})
}
// HTTP в локальной сети не предоставляет crypto.randomUUID во всех браузерах.
function cryptoId() {
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (v) =>
v.toString(16).padStart(2, '0'),
).join('')
}
function connect() {
if (isDisposed || (socket && socket.readyState !== WebSocket.CLOSED)) return
socket = new WebSocket(`ws://${location.host}/ws`)
socket.onmessage = (event) => {
acceptState(JSON.parse(event.data) as State)
isConnected.value = true
isApproved.value = true
}
socket.onclose = () => {
isConnected.value = false
}
socket.onerror = () => {
isConnected.value = false
}
}
async function pair() {
await run(async () => {
const response = await fetch('/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.value }),
})
if (!response.ok)
throw new Error('Код неверен или превышен лимит попыток. Повторите через минуту.')
pairingId.value = ((await response.json()) as { id: string }).id
code.value = ''
})
}
async function tick() {
if (bridge) {
await refreshHost()
return
}
if (pairingId.value) {
const response = await fetch(`/pair/${pairingId.value}`)
if (response.status === 200) {
pairingId.value = ''
isApproved.value = true
socket?.close()
socket = null
connect()
} else if (response.status !== 202) {
pairingId.value = ''
throw new Error('Запрос подключения истёк; введите код заново')
}
} else if (isApproved.value && !isConnected.value) connect()
}
onMounted(async () => {
await run(async () => {
if (bridge) {
unsubscribe = bridge.subscribe(acceptState)
acceptState(await bridge.state())
isConnected.value = true
await refreshHost()
} else connect()
})
let isTicking = false
timer = setInterval(() => {
if (isTicking) return
isTicking = true
void tick()
.catch((error: unknown) => {
message.error(error instanceof Error ? error.message : 'Ошибка связи')
})
.finally(() => {
isTicking = false
})
}, 1000)
})
onUnmounted(() => {
isDisposed = true
clearInterval(timer)
unsubscribe?.()
socket?.close()
})
</script>
<template>
<main class="control">
<h1>Вечерний детектив · SCREEN-001</h1>
<n-alert type="info"
>Технический стенд: синтетические медиа, два окна и локальная связь.</n-alert
>
<n-card v-if="bridge" title="Дисплей показа">
<n-space vertical>
<n-select
v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
placeholder="Выберите внешний дисплей"
:input-props="{ 'aria-label': 'Дисплей показа' }"
/>
<n-alert v-if="!host.displays.length" type="warning"
>Подключите внешний дисплей и включите расширенный рабочий стол.</n-alert
>
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
>Открыть показ</n-button
>
</n-space>
</n-card>
<n-card v-if="bridge" title="Телефон по локальной сети">
<n-space vertical>
<n-select
v-model:value="address"
:options="host.lan.map((a) => ({ label: a.address, value: a.address }))"
placeholder="Адрес доверенной сети / точки доступа"
:input-props="{ 'aria-label': 'Сетевой интерфейс' }"
/>
<n-space>
<n-button
:disabled="!address || isBusy"
@click="
run(async () => {
await bridge!.lan(address)
await refreshHost()
})
"
>Включить / сбросить подключения</n-button
>
<n-button
:disabled="!host.url || isBusy"
@click="
run(async () => {
await bridge!.lan(null)
await refreshHost()
})
"
>Отключить и отозвать доступ</n-button
>
</n-space>
<p v-if="host.url">На телефоне откройте {{ host.url }} · код {{ host.code }}</p>
<p>Только доверенная сеть. HTTP не шифрует команды. Не публикуйте порт в интернет.</p>
<n-button
v-for="id in host.pending"
:key="id"
@click="
run(async () => {
await bridge!.approve(id)
await refreshHost()
})
"
>Подтвердить запрос {{ id.slice(0, 8) }}</n-button
>
</n-space>
</n-card>
<n-card v-if="isPhone && !isConnected" title="Подключение к компьютеру">
<n-space vertical>
<n-input
v-model:value="code"
placeholder="Код с компьютера"
:input-props="{ 'aria-label': 'Код подключения' }"
:maxlength="6"
/>
<n-button :disabled="isBusy || !!pairingId || code.length !== 6" @click="pair">{{
pairLabel
}}</n-button>
</n-space>
</n-card>
<n-card title="Локальное воспроизведение">
<n-space vertical>
<n-alert v-if="!isConnected" type="warning"
>Нет связи. Показ на компьютере продолжается; команды недоступны.</n-alert
>
<n-select
v-model:value="media"
:options="[
{ label: 'Аудио · WAV PCM 48 кГц', value: 'audio' },
{ label: 'Видео · H.264 + AAC, 720p30', value: 'video' },
]"
:input-props="{ 'aria-label': 'Тестовое медиа' }"
/>
<n-space>
<n-button :disabled="!isConnected || isBusy" @click="send('play')"
>Пуск с начала</n-button
>
<n-button :disabled="!isConnected || isBusy" @click="send('pause')">Пауза</n-button>
<n-button :disabled="!isConnected || isBusy" @click="send('stop')">Стоп</n-button>
</n-space>
<label for="position">Позиция, секунды</label>
<n-input-number
v-model:value="position"
:input-props="{ id: 'position' }"
:min="0"
:max="60"
/>
<n-button :disabled="!isConnected || isBusy" @click="send('seek')">Перемотать</n-button>
<p role="status">
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
{{ state?.revision ?? 0 }}
</p>
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
<p>{{ metrics }}</p>
<p>
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть
телефона, физический звук или первый кадр проектора.
</p>
</n-space>
</n-card>
</main>
</template>
<style scoped>
.control {
display: grid;
gap: 16px;
margin: auto;
max-width: 900px;
padding: 16px;
}
p {
overflow-wrap: anywhere;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import type { Command, MediaKind, State } from '../types/screen'
const audio = ref<HTMLAudioElement | null>(null)
const video = ref<HTMLVideoElement | null>(null)
const kind = ref<MediaKind>('audio')
const bridge = window.screenBridge!
let unsubscribe: (() => void) | undefined
let generation = 0
let lastRevision = -1
let commandId = ''
let lastCommand: Command | null = null
async function metadata(media: HTMLMediaElement) {
if (media.readyState >= 1) return
await new Promise<void>((resolve, reject) => {
const finish = (error?: Error) => {
clearTimeout(timeout)
media.removeEventListener('loadedmetadata', ready)
media.removeEventListener('error', failed)
if (error) reject(error)
else resolve()
}
const ready = () => finish()
const failed = () => finish(new Error('Не удалось прочитать тестовое медиа'))
const timeout = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
media.addEventListener('loadedmetadata', ready, { once: true })
media.addEventListener('error', failed, { once: true })
if (media.error) failed()
})
}
async function apply(state: State) {
if (state.revision <= lastRevision) return
lastRevision = state.revision
const command = state.command
if (state.status !== 'pending' || !command || command.id === commandId) return
commandId = command.id
lastCommand = command
const current = ++generation
const media = command.media === 'audio' ? audio.value : video.value
const other = command.media === 'audio' ? video.value : audio.value
other?.pause()
kind.value = command.media
if (!media) return
try {
if (command.action === 'pause' || command.action === 'stop') media.pause()
await metadata(media)
if (current !== generation) return
if (command.action === 'play' || command.action === 'stop') media.currentTime = 0
if (command.action === 'seek') media.currentTime = Math.min(command.position, media.duration)
if (command.action === 'play') {
await media.play()
if (current !== generation) return
}
const status = command.action === 'stop' ? 'stopped' : media.paused ? 'paused' : 'playing'
bridge.report({ id: command.id, status })
} catch (error) {
if (current === generation)
bridge.report({
id: command.id,
status: 'error',
error: error instanceof Error ? error.message : 'Ошибка воспроизведения',
})
}
}
function activeMedia() {
return lastCommand?.media === 'audio' ? audio.value : video.value
}
function ended(event: Event) {
const media = activeMedia()
if (lastCommand && event.target === media && media?.ended)
bridge.report({ id: lastCommand.id, status: 'ended' })
}
function failed(event: Event) {
if (lastCommand && event.target === activeMedia())
bridge.report({
id: lastCommand.id,
status: 'error',
error: 'Ошибка локального медиа; проверьте наличие fixtures',
})
}
onMounted(async () => {
for (const media of [audio.value, video.value]) if (media) media.volume = 0.35
unsubscribe = bridge.subscribe((state) => {
void apply(state)
})
await apply(await bridge.state())
})
onUnmounted(() => {
generation++
unsubscribe?.()
audio.value?.pause()
video.value?.pause()
})
</script>
<template>
<main class="output">
<audio
ref="audio"
:src="'screen-app://app/media/audio'"
preload="auto"
@ended="ended"
@error="failed"
/>
<video
v-show="kind === 'video'"
ref="video"
:src="'screen-app://app/media/video'"
preload="auto"
@ended="ended"
@error="failed"
/>
</main>
</template>
<style scoped>
.output {
background: var(--brand-background);
height: 100vh;
overflow: hidden;
width: 100vw;
}
video {
height: 100%;
object-fit: contain;
width: 100%;
}
</style>