up
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user