up
This commit is contained in:
+29
-167
@@ -1,42 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCard, NInput, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import { NAlert, NButton, NCard, 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 host = ref<HostInfo>({ displays: [] })
|
||||
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 values = (state.value?.samples ?? [])
|
||||
.filter((sample) => sample.media === media.value)
|
||||
.map((sample) => sample.milliseconds)
|
||||
.sort((a, b) => a - b)
|
||||
if (!values.length) return 'Замеров пока нет'
|
||||
const middle = Math.floor(values.length / 2)
|
||||
const median = values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
|
||||
return `n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
|
||||
})
|
||||
const statusLabels: Record<State['status'], string> = {
|
||||
idle: 'Ожидание',
|
||||
@@ -47,10 +30,8 @@ const statusLabels: Record<State['status'], string> = {
|
||||
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
|
||||
@@ -69,83 +50,28 @@ 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 (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
await bridge.command({
|
||||
id: crypto.randomUUID(),
|
||||
action,
|
||||
media: media.value,
|
||||
position: position.value ?? 0,
|
||||
})
|
||||
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()
|
||||
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
unsubscribe = bridge.subscribe(acceptState)
|
||||
acceptState(await bridge.state())
|
||||
await refreshHost()
|
||||
})
|
||||
let isTicking = false
|
||||
timer = setInterval(() => {
|
||||
if (isTicking) return
|
||||
isTicking = true
|
||||
void tick()
|
||||
void refreshHost()
|
||||
.catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : 'Ошибка связи')
|
||||
})
|
||||
@@ -155,10 +81,8 @@ onMounted(async () => {
|
||||
}, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
isDisposed = true
|
||||
clearInterval(timer)
|
||||
unsubscribe?.()
|
||||
socket?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -166,7 +90,7 @@ onUnmounted(() => {
|
||||
<main class="control">
|
||||
<h1>Вечерний детектив · SCREEN-001</h1>
|
||||
<n-alert type="info"
|
||||
>Технический стенд: синтетические медиа, два окна и локальная связь.</n-alert
|
||||
>Технический стенд: синтетические медиа, два окна и локальное воспроизведение.</n-alert
|
||||
>
|
||||
<n-card v-if="bridge" title="Дисплей показа">
|
||||
<n-space vertical>
|
||||
@@ -184,69 +108,9 @@ onUnmounted(() => {
|
||||
>
|
||||
</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-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<n-select
|
||||
v-model:value="media"
|
||||
:options="[
|
||||
@@ -256,11 +120,9 @@ onUnmounted(() => {
|
||||
: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-button :disabled="!bridge || isBusy" @click="send('play')">Пуск с начала</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('pause')">Пауза</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('stop')">Стоп</n-button>
|
||||
</n-space>
|
||||
<label for="position">Позиция, секунды</label>
|
||||
<n-input-number
|
||||
@@ -269,7 +131,7 @@ onUnmounted(() => {
|
||||
:min="0"
|
||||
:max="60"
|
||||
/>
|
||||
<n-button :disabled="!isConnected || isBusy" @click="send('seek')">Перемотать</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('seek')">Перемотать</n-button>
|
||||
<p role="status">
|
||||
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
|
||||
{{ state?.revision ?? 0 }}
|
||||
@@ -277,8 +139,8 @@ onUnmounted(() => {
|
||||
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
|
||||
<p>{{ metrics }}</p>
|
||||
<p>
|
||||
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют сеть
|
||||
телефона, физический звук или первый кадр проектора.
|
||||
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют
|
||||
физический звук или первый кадр проектора.
|
||||
</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
+1
-7
@@ -11,7 +11,7 @@ export interface State {
|
||||
command: Command | null
|
||||
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||
error: string
|
||||
samples: { media: MediaKind; origin: 'computer' | 'phone'; milliseconds: number }[]
|
||||
samples: { media: MediaKind; milliseconds: number }[]
|
||||
}
|
||||
export interface DisplayInfo {
|
||||
id: number
|
||||
@@ -19,10 +19,6 @@ export interface DisplayInfo {
|
||||
}
|
||||
export interface HostInfo {
|
||||
displays: DisplayInfo[]
|
||||
lan: { address: string; family: string }[]
|
||||
url: string
|
||||
code: string
|
||||
pending: string[]
|
||||
}
|
||||
export interface ScreenBridge {
|
||||
command: (command: Command) => Promise<void>
|
||||
@@ -31,6 +27,4 @@ export interface ScreenBridge {
|
||||
subscribe: (listener: (state: State) => void) => () => void
|
||||
host: () => Promise<HostInfo>
|
||||
show: (displayId: number) => Promise<void>
|
||||
lan: (address: string | null) => Promise<void>
|
||||
approve: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user