up
This commit is contained in:
+205
-177
@@ -209,190 +209,217 @@ const gameOptions = computed(() => {
|
||||
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card v-if="bridge && isReady" title="Игра">
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="game-connection-status"
|
||||
:type="state.connection === 'connected' ? 'success' : 'error'"
|
||||
>
|
||||
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<div class="connection-content">
|
||||
<div v-if="selectedKey" class="current-game">
|
||||
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
|
||||
</div>
|
||||
<template v-if="state.authenticated">
|
||||
<div class="game-selection">
|
||||
<n-select
|
||||
class="game-select"
|
||||
v-model:value="gameId"
|
||||
clearable
|
||||
:options="gameOptions"
|
||||
:disabled="isBusy || state.busy"
|
||||
placeholder="Выберите игру"
|
||||
:input-props="{ 'aria-label': 'Игра на сервере' }"
|
||||
/>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Обновить список игр"
|
||||
title="Обновить список игр"
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'games' })"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 7v5h-5M4 17v-5h5" />
|
||||
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
||||
</svg>
|
||||
</n-button>
|
||||
<div v-if="bridge && isReady" class="game-grid">
|
||||
<n-card title="Игра">
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="game-selection-status"
|
||||
:type="selectedKey ? 'success' : 'error'"
|
||||
>
|
||||
{{ selectedKey ? 'Выбрана' : 'Не выбрана' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<div class="connection-content">
|
||||
<div v-if="selectedKey" class="current-game">
|
||||
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
|
||||
</div>
|
||||
<p v-if="!gameOptions.length">Нет игр.</p>
|
||||
<template v-if="state.authenticated">
|
||||
<div v-if="!selectedKey" class="game-selection">
|
||||
<n-select
|
||||
class="game-select"
|
||||
v-model:value="gameId"
|
||||
clearable
|
||||
:options="gameOptions"
|
||||
:disabled="isBusy || state.busy"
|
||||
placeholder="Выберите игру"
|
||||
:input-props="{ 'aria-label': 'Игра на сервере' }"
|
||||
/>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Обновить список игр"
|
||||
title="Обновить список игр"
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'games' })"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 7v5h-5M4 17v-5h5" />
|
||||
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button
|
||||
v-if="!selectedKey"
|
||||
class="game-confirm"
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@click="run({ action: 'select-game', gameId: gameId! })"
|
||||
>Выбрать игру</n-button
|
||||
>
|
||||
</template>
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||
</p>
|
||||
<n-alert v-if="state.preparation.error" type="warning" class="connection-error">
|
||||
{{ state.preparation.error }}
|
||||
<n-button
|
||||
:disabled="isBusy || state.busy || state.preparation.active"
|
||||
@click="run({ action: 'retry-materials' })"
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<template v-if="state.busy">
|
||||
<p v-if="state.progress" role="status">
|
||||
Файлов: {{ state.progress.completedFiles }}/{{ state.progress.totalFiles }} ·
|
||||
{{ (state.progress.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||
</p>
|
||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||
</template>
|
||||
<n-collapse v-if="selectedKey">
|
||||
<n-collapse-item
|
||||
:title="`Материалы — загружено ${loadedMaterials} из ${state.materials.length}`"
|
||||
name="materials"
|
||||
>
|
||||
<div class="materials-list">
|
||||
<p v-if="!state.materials.length">В игре нет материалов.</p>
|
||||
<n-table
|
||||
v-if="state.materials.length"
|
||||
class="materials-table"
|
||||
size="small"
|
||||
:single-line="false"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Материал</th>
|
||||
<th scope="col" class="status-column">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="group in materialGroups" :key="group.id">
|
||||
<tr>
|
||||
<th scope="rowgroup" colspan="2">{{ group.name }}</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in group.rows"
|
||||
:key="`${row.material.id}-${index}`"
|
||||
class="material-row"
|
||||
>
|
||||
<td class="material-name">
|
||||
{{ row.name }}
|
||||
<span v-if="row.material.status === 'error'" class="material-error">{{
|
||||
row.material.error
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="material-status-cell">
|
||||
<span
|
||||
v-if="row.material.status === 'ready' || row.material.status === 'loading'"
|
||||
role="status"
|
||||
:aria-label="materialLabels[row.material.status]"
|
||||
:title="materialLabels[row.material.status]"
|
||||
class="material-state"
|
||||
:class="{
|
||||
'material-ready': row.material.status === 'ready',
|
||||
'material-unavailable': row.material.status !== 'ready',
|
||||
}"
|
||||
>
|
||||
<n-spin v-if="row.material.status === 'loading'" size="small" />
|
||||
<svg
|
||||
v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<n-button
|
||||
v-else
|
||||
square
|
||||
size="small"
|
||||
:disabled="isBusy || state.busy"
|
||||
:aria-label="`${row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'}: ${row.name}`"
|
||||
:title="row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'"
|
||||
@click="
|
||||
run({
|
||||
action: 'download-material',
|
||||
key: selectedKey!,
|
||||
assetId: row.material.id,
|
||||
})
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<div class="materials-actions">
|
||||
<n-button text size="small" @click="openMaterialsFolder">Открыть папку</n-button>
|
||||
<n-button
|
||||
:disabled="
|
||||
!state.authenticated || state.currentGameId === null || isBusy || state.busy
|
||||
"
|
||||
@click="run({ action: 'download', gameId: state.currentGameId! })"
|
||||
>Загрузить материалы</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||
</div>
|
||||
</n-card>
|
||||
<ScreenConnectionPanel
|
||||
:state="state"
|
||||
:is-busy="isBusy"
|
||||
@action="run"
|
||||
>
|
||||
<template v-if="state.authenticated">
|
||||
<n-button
|
||||
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
|
||||
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
|
||||
v-if="state.connection !== 'connected'"
|
||||
:disabled="!canConnectCurrent || isBusy || state.busy"
|
||||
@click="run({ action: 'connect' })"
|
||||
>Подключиться</n-button
|
||||
>
|
||||
</template>
|
||||
<ScreenConnectionPanel :state="state" :is-busy="isBusy" @action="run" />
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||
</p>
|
||||
<n-alert v-if="state.preparation.error" type="warning" class="connection-error">
|
||||
{{ state.preparation.error }}
|
||||
<n-button
|
||||
:disabled="isBusy || state.busy || state.preparation.active"
|
||||
@click="run({ action: 'retry-materials' })"
|
||||
>Повторить подготовку</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<template v-if="state.busy">
|
||||
<p v-if="state.progress" role="status">
|
||||
Файлов: {{ state.progress.completedFiles }}/{{ state.progress.totalFiles }} ·
|
||||
{{ (state.progress.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||
</p>
|
||||
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
|
||||
</template>
|
||||
<n-collapse v-if="selectedKey">
|
||||
<n-collapse-item
|
||||
:title="`Материалы — загружено ${loadedMaterials} из ${state.materials.length}`"
|
||||
name="materials"
|
||||
>
|
||||
<div class="materials-list">
|
||||
<p v-if="!state.materials.length">В игре нет материалов.</p>
|
||||
<n-table
|
||||
v-if="state.materials.length"
|
||||
class="materials-table"
|
||||
size="small"
|
||||
:single-line="false"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Материал</th>
|
||||
<th scope="col" class="status-column">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="group in materialGroups" :key="group.id">
|
||||
<tr>
|
||||
<th scope="rowgroup" colspan="2">{{ group.name }}</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in group.rows"
|
||||
:key="`${row.material.id}-${index}`"
|
||||
class="material-row"
|
||||
>
|
||||
<td class="material-name">
|
||||
{{ row.name }}
|
||||
<span v-if="row.material.status === 'error'" class="material-error">{{
|
||||
row.material.error
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="material-status-cell">
|
||||
<span
|
||||
v-if="row.material.status === 'ready' || row.material.status === 'loading'"
|
||||
role="status"
|
||||
:aria-label="materialLabels[row.material.status]"
|
||||
:title="materialLabels[row.material.status]"
|
||||
class="material-state"
|
||||
:class="{
|
||||
'material-ready': row.material.status === 'ready',
|
||||
'material-unavailable': row.material.status !== 'ready',
|
||||
}"
|
||||
>
|
||||
<n-spin v-if="row.material.status === 'loading'" size="small" />
|
||||
<svg
|
||||
v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<n-button
|
||||
v-else
|
||||
square
|
||||
size="small"
|
||||
:disabled="isBusy || state.busy"
|
||||
:aria-label="`${row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'}: ${row.name}`"
|
||||
:title="row.material.status === 'error' ? 'Повторить загрузку' : 'Загрузить'"
|
||||
@click="
|
||||
run({
|
||||
action: 'download-material',
|
||||
key: selectedKey!,
|
||||
assetId: row.material.id,
|
||||
})
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<div class="materials-actions">
|
||||
<n-button text size="small" @click="openMaterialsFolder">Открыть папку</n-button>
|
||||
<n-button
|
||||
:disabled="
|
||||
!state.authenticated || state.currentGameId === null || isBusy || state.busy
|
||||
"
|
||||
@click="run({ action: 'download', gameId: state.currentGameId! })"
|
||||
>Загрузить материалы</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</ScreenConnectionPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
}
|
||||
|
||||
.game-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.materials-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -512,7 +539,8 @@ const gameOptions = computed(() => {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.connection-content > .auth-action {
|
||||
.connection-content > .auth-action,
|
||||
.connection-content > .game-confirm {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
|
||||
+135
-81
@@ -4,9 +4,11 @@ import {
|
||||
NButton,
|
||||
NCard,
|
||||
NInputNumber,
|
||||
NProgress,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from 'naive-ui'
|
||||
@@ -26,8 +28,8 @@ import ConnectionPanel from './ConnectionPanel.vue'
|
||||
import MusicPlanPanel from './MusicPlanPanel.vue'
|
||||
import { useOnlineConnection } from '../composables/useOnlineConnection'
|
||||
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
||||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||||
import { playbackControl } from '../utils/playbackControl'
|
||||
import { formatTimer } from '../utils/presentation'
|
||||
|
||||
const {
|
||||
state: onlineState,
|
||||
@@ -46,6 +48,11 @@ const originalPlan = ref('')
|
||||
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
|
||||
const bridge = window.screenBridge
|
||||
const state = ref<State | null>(null)
|
||||
const mainProgress = computed(() => {
|
||||
const channel = state.value?.channels.main
|
||||
if (!channel?.duration || channel.duration <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (channel.position / channel.duration) * 100))
|
||||
})
|
||||
const host = ref<HostInfo>({
|
||||
displays: [],
|
||||
notice: '',
|
||||
@@ -55,7 +62,6 @@ const host = ref<HostInfo>({
|
||||
const display = ref<number | null>(null)
|
||||
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
|
||||
const isBusy = ref(false)
|
||||
const timerDisplay = useScreenTimer(() => state.value)
|
||||
const preview = ref<PreviewFrame | null>(null)
|
||||
const previewURL = computed(() =>
|
||||
preview.value?.session === state.value?.session &&
|
||||
@@ -63,12 +69,6 @@ const previewURL = computed(() =>
|
||||
? preview.value?.dataURL
|
||||
: null,
|
||||
)
|
||||
const timerLabels = {
|
||||
stopped: 'Не запущен',
|
||||
running: 'Идёт',
|
||||
paused: 'Пауза',
|
||||
finished: 'Завершён',
|
||||
}
|
||||
let previewTimer: ReturnType<typeof setInterval> | undefined
|
||||
let previewBusy = false
|
||||
async function refreshPreview() {
|
||||
@@ -261,31 +261,54 @@ onUnmounted(() => {
|
||||
"
|
||||
/>
|
||||
<n-card v-if="bridge" title="Экран">
|
||||
<n-space vertical>
|
||||
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
||||
<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
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="display-connection-status"
|
||||
:type="host.displays.length ? 'success' : 'error'"
|
||||
>
|
||||
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
|
||||
>Открыть показ</n-button
|
||||
>
|
||||
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
||||
>Закрыть показ</n-button
|
||||
>
|
||||
<template v-if="state">
|
||||
<label class="mute"
|
||||
>Выключить весь звук
|
||||
<n-switch
|
||||
:value="state.muted"
|
||||
aria-label="Выключить весь звук"
|
||||
@update:value="send({ action: 'mute', enabled: $event })"
|
||||
/></label>
|
||||
{{ host.displays.length ? 'Подключён' : 'Не подключён' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<div class="screen-grid">
|
||||
<n-space vertical>
|
||||
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
||||
<n-select
|
||||
v-model:value="display"
|
||||
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
|
||||
placeholder="Выберите внешний дисплей"
|
||||
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
||||
/>
|
||||
<n-space align="center">
|
||||
<n-button
|
||||
v-if="!state?.outputOpen"
|
||||
:disabled="display === null || isBusy"
|
||||
@click="run(() => bridge!.show(display!))"
|
||||
>Открыть показ</n-button
|
||||
>
|
||||
<n-button v-else :disabled="isBusy" @click="run(() => bridge!.closeOutput())"
|
||||
>Закрыть показ</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="state"
|
||||
square
|
||||
:type="state.muted ? 'error' : 'default'"
|
||||
:ghost="state.muted"
|
||||
:disabled="isBusy"
|
||||
:aria-label="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
|
||||
:title="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
|
||||
:aria-pressed="state.muted"
|
||||
@click="send({ action: 'mute', enabled: !state.muted })"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M11 5 6 9H3v6h3l5 4z" />
|
||||
<path v-if="state.muted" d="m16 9 6 6m0-6-6 6" />
|
||||
<path v-else d="M15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
|
||||
</svg>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<div v-if="state" class="screen-preview">
|
||||
<img
|
||||
v-if="previewURL"
|
||||
class="preview"
|
||||
@@ -302,14 +325,17 @@ onUnmounted(() => {
|
||||
: 'Предпросмотр недоступен'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
<template v-if="state">
|
||||
<section class="presentation-grid">
|
||||
<n-card title="План экранов">
|
||||
<n-space vertical>
|
||||
<ScreenPlanPanel
|
||||
:game-name="state.catalog.name"
|
||||
:cover="state.catalog.cover"
|
||||
:background="state.catalog.background"
|
||||
:steps="state.catalog.steps"
|
||||
:current-index="state.scene.stepIndex"
|
||||
:draft="planDraft"
|
||||
@@ -335,51 +361,45 @@ onUnmounted(() => {
|
||||
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
|
||||
@command="send"
|
||||
>
|
||||
<template #main-controls>
|
||||
<label for="position-main">Позиция, секунды</label>
|
||||
<n-input-number
|
||||
v-model:value="positions.main"
|
||||
:input-props="{ id: 'position-main' }"
|
||||
:min="0"
|
||||
:max="state.channels.main.duration ?? undefined"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="
|
||||
!state.outputOpen || !state.channels.main.source || isBusy || isPlanSaving
|
||||
"
|
||||
@click="send({ action: 'seek', channel: 'main', position: positions.main ?? 0 })"
|
||||
>Перемотать</n-button
|
||||
>
|
||||
<label for="volume-main">Громкость, %</label>
|
||||
<n-input-number
|
||||
:value="state.channels.main.volume"
|
||||
:input-props="{ id: 'volume-main' }"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="isBusy || isPlanSaving"
|
||||
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
|
||||
/>
|
||||
<p role="status">
|
||||
{{ statusLabels[state.channels.main.status] }} ·
|
||||
{{ state.channels.main.position.toFixed(1) }} /
|
||||
{{ state.channels.main.duration?.toFixed(1) ?? '—' }} с
|
||||
</p>
|
||||
<n-alert v-if="state.channels.main.error" type="error">{{
|
||||
<template #main-volume="{ index, active }">
|
||||
<div class="plan-volume-control">
|
||||
<label :for="`volume-main-${index}`" title="Громкость, %">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
|
||||
</svg>
|
||||
</label>
|
||||
<n-input-number
|
||||
class="plan-volume"
|
||||
:value="state.channels.main.volume"
|
||||
:input-props="{ id: `volume-main-${index}`, 'aria-label': 'Громкость, %' }"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="!active || isBusy || isPlanSaving"
|
||||
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #main-controls="{ active }">
|
||||
<div class="playback-progress-row">
|
||||
<n-progress
|
||||
class="playback-progress-bar"
|
||||
type="line"
|
||||
:percentage="active ? mainProgress : 0"
|
||||
:show-indicator="false"
|
||||
aria-label="Прогресс воспроизведения"
|
||||
/>
|
||||
<p v-if="active" role="status" class="playback-progress">
|
||||
{{ statusLabels[state.channels.main.status] }} ·
|
||||
{{ formatTimer(Math.floor(state.channels.main.position) * 1000) }} /
|
||||
{{ state.channels.main.duration === null ? '—' : formatTimer(state.channels.main.duration * 1000) }}
|
||||
</p>
|
||||
</div>
|
||||
<n-alert v-if="active && state.channels.main.error" type="error">{{
|
||||
state.channels.main.error
|
||||
}}</n-alert>
|
||||
</template>
|
||||
</ScreenPlanPanel>
|
||||
<p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</p>
|
||||
<p
|
||||
v-if="
|
||||
state.timer.durationMs > 0 &&
|
||||
(state.scene.mode === 7 || state.timer.status === 'running')
|
||||
"
|
||||
role="status"
|
||||
class="timer-status"
|
||||
>
|
||||
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
|
||||
</p>
|
||||
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
|
||||
<n-button
|
||||
v-if="state.scene.status === 'error'"
|
||||
@@ -504,10 +524,21 @@ onUnmounted(() => {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
}
|
||||
|
||||
.screen-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
}
|
||||
|
||||
.screen-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--brand-background);
|
||||
display: block;
|
||||
max-height: 50vh;
|
||||
max-height: 25vh;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -520,7 +551,35 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-status {
|
||||
.plan-volume-control {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-volume {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.playback-progress-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.playback-progress-bar {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playback-progress {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playback-progress {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -528,9 +587,4 @@ p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mute {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,7 +114,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<main ref="surface" class="output" :data-mode="state?.scene.mode">
|
||||
<img
|
||||
v-if="state?.catalog.background && state.scene.mode !== 1"
|
||||
v-if="state?.catalog.background"
|
||||
:key="`${state.session}:${state.scene.revision}:background`"
|
||||
class="background"
|
||||
:class="{ failed: imageError }"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpace } from 'naive-ui'
|
||||
import { NButton, NCard, NSpace, NTag } from 'naive-ui'
|
||||
import type { OnlineIntent, OnlineState } from '../types/online'
|
||||
defineProps<{ state: OnlineState; isBusy: boolean }>()
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
||||
@@ -8,30 +8,46 @@ function run(intent: OnlineIntent) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<p v-if="state.screenId">Экран №{{ state.screenId }}</p>
|
||||
<template v-if="state.screenId">
|
||||
<p role="status" data-testid="sound-assignment">
|
||||
{{
|
||||
state.soundEnabled === null
|
||||
? 'Назначение звука пока неизвестно'
|
||||
: state.soundEnabled
|
||||
? 'Экран назначен для звука'
|
||||
: 'Экран не назначен для звука'
|
||||
}}
|
||||
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
|
||||
· Последнее состояние
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<n-space v-if="state.screenId">
|
||||
<n-button
|
||||
v-if="state.soundEnabled !== true"
|
||||
:disabled="isBusy || state.busy || !state.authenticated"
|
||||
@click="run({ action: 'sound' })"
|
||||
>Назначить звук этому экрану</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })"
|
||||
>Отключить управление</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-card title="Экран игры">
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
role="status"
|
||||
data-testid="game-connection-status"
|
||||
:type="state.connection === 'connected' ? 'success' : 'error'"
|
||||
>
|
||||
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
|
||||
</n-tag>
|
||||
</template>
|
||||
<slot />
|
||||
<n-space v-if="state.screenId" align="center">
|
||||
<span>Экран №{{ state.screenId }}</span>
|
||||
<n-button
|
||||
:disabled="isBusy || state.busy"
|
||||
@click="run({ action: 'disconnect' })"
|
||||
>Отключиться</n-button
|
||||
>
|
||||
</n-space>
|
||||
<template v-if="state.screenId">
|
||||
<p role="status" data-testid="sound-assignment">
|
||||
{{
|
||||
state.soundEnabled === null
|
||||
? 'Назначение звука пока неизвестно'
|
||||
: state.soundEnabled
|
||||
? 'Экран назначен для звука'
|
||||
: 'Экран не назначен для звука'
|
||||
}}
|
||||
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
|
||||
· Последнее состояние
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<n-space v-if="state.screenId">
|
||||
<n-button
|
||||
v-if="state.soundEnabled !== true"
|
||||
:disabled="isBusy || state.busy || !state.authenticated"
|
||||
@click="run({ action: 'sound' })"
|
||||
>Назначить звук этому экрану</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
+204
-103
@@ -4,8 +4,12 @@ import { computed } from 'vue'
|
||||
import { playbackControl } from '../utils/playbackControl'
|
||||
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import ScreenStepPreview from './ScreenStepPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string
|
||||
cover: string | null
|
||||
background: string | null
|
||||
steps: ScreenStep[]
|
||||
currentIndex: number | null
|
||||
draft: ScreenStep[]
|
||||
@@ -36,6 +40,11 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
value: Number(mode) as ScreenMode,
|
||||
label,
|
||||
}))
|
||||
function timerLabel(index: number) {
|
||||
if (props.currentIndex !== index) return 'Старт'
|
||||
if (props.timerStatus === 'running') return 'Пауза'
|
||||
return props.timerStatus === 'paused' ? 'Продолжить' : 'Старт'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,15 +68,41 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
|
||||
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
|
||||
<template v-if="isEditing">
|
||||
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
|
||||
<span>Шаг {{ index + 1 }}</span>
|
||||
<n-select
|
||||
:value="step.mode"
|
||||
:options="modeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||
@update:value="emit('updateStep', index, { mode: $event })"
|
||||
/>
|
||||
<div v-for="(step, index) in draft" :key="index" class="plan-step">
|
||||
<div class="plan-edit-header">
|
||||
<div class="plan-step-preview">
|
||||
<ScreenStepPreview
|
||||
:step="step"
|
||||
:game-name="gameName"
|
||||
:cover="cover"
|
||||
:background="background"
|
||||
/>
|
||||
</div>
|
||||
<div class="plan-edit-controls">
|
||||
<n-select
|
||||
:value="step.mode"
|
||||
:options="modeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||
@update:value="emit('updateStep', index, { mode: $event })"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || index === 0"
|
||||
:aria-label="`Поднять шаг ${index + 1}`"
|
||||
@click="emit('move', index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || index === draft.length - 1"
|
||||
:aria-label="`Опустить шаг ${index + 1}`"
|
||||
@click="emit('move', index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<n-input
|
||||
v-if="step.mode === 8"
|
||||
:value="step.text"
|
||||
@@ -95,24 +130,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
|
||||
/>
|
||||
</template>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || index === 0"
|
||||
:aria-label="`Поднять шаг ${index + 1}`"
|
||||
@click="emit('move', index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || index === draft.length - 1"
|
||||
:aria-label="`Опустить шаг ${index + 1}`"
|
||||
@click="emit('move', index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-button :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||||
<n-button :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</n-button>
|
||||
@@ -120,90 +138,121 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="!steps.length">План экранов пуст.</p>
|
||||
<n-space
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
vertical
|
||||
class="plan-step"
|
||||
:class="{ 'plan-step-current': currentIndex === index }"
|
||||
:data-step-index="index"
|
||||
:aria-current="currentIndex === index ? 'step' : undefined"
|
||||
>
|
||||
<span
|
||||
>{{ index + 1 }}. {{ modeLabels[step.mode]
|
||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||
><template v-if="step.mode === 7"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
></span
|
||||
>
|
||||
<n-space>
|
||||
<n-button
|
||||
:type="currentIndex === index ? 'primary' : 'default'"
|
||||
:disabled="isBusy || currentIndex === index"
|
||||
@click="emit('command', { action: 'step', index })"
|
||||
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
||||
>
|
||||
<template v-if="currentIndex === index && step.mode === 7">
|
||||
<n-button
|
||||
:disabled="isBusy"
|
||||
@click="
|
||||
emit('command', {
|
||||
action:
|
||||
timerStatus === 'running'
|
||||
? 'timer-pause'
|
||||
: timerStatus === 'paused'
|
||||
? 'timer-resume'
|
||||
: 'timer-start',
|
||||
})
|
||||
"
|
||||
<div class="plan-step-header">
|
||||
<div class="plan-step-preview">
|
||||
<ScreenStepPreview
|
||||
:step="step"
|
||||
:game-name="gameName"
|
||||
:cover="cover"
|
||||
:background="background"
|
||||
/>
|
||||
</div>
|
||||
<div class="plan-step-content">
|
||||
<span
|
||||
>{{ modeLabels[step.mode]
|
||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||
><template v-if="step.mode === 7"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
></span
|
||||
>
|
||||
{{
|
||||
timerStatus === 'running'
|
||||
? 'Пауза'
|
||||
: timerStatus === 'paused'
|
||||
? 'Продолжить'
|
||||
: 'Старт'
|
||||
}}
|
||||
</n-button>
|
||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
|
||||
>Сбросить</n-button
|
||||
>
|
||||
</template>
|
||||
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
|
||||
<n-button
|
||||
:disabled="isBusy || !canPlayMain"
|
||||
@click="emit('command', { action: mainControl.action, channel: 'main' })"
|
||||
>{{ mainControl.label }}</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || !canPlayMain"
|
||||
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||||
>Стоп</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || !canPlayMain"
|
||||
@click="emit('command', { action: 'replay', channel: 'main' })"
|
||||
>Сначала</n-button
|
||||
>
|
||||
</template>
|
||||
</n-space>
|
||||
<slot
|
||||
v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"
|
||||
name="main-controls"
|
||||
/>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="isBusy || currentIndex === null || currentIndex <= 0"
|
||||
@click="emit('command', { action: 'step', index: (currentIndex ?? 0) - 1 })"
|
||||
>Предыдущий шаг</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="isBusy || !steps.length || (currentIndex ?? -1) >= steps.length - 1"
|
||||
@click="emit('command', { action: 'step', index: (currentIndex ?? -1) + 1 })"
|
||||
>Следующий шаг</n-button
|
||||
>
|
||||
</n-space>
|
||||
<div class="plan-step-actions">
|
||||
<n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false">
|
||||
<template v-if="step.mode === 7">
|
||||
<n-button
|
||||
square
|
||||
:aria-label="timerLabel(index)"
|
||||
:title="timerLabel(index)"
|
||||
:disabled="isBusy || currentIndex !== index"
|
||||
@click="
|
||||
emit('command', {
|
||||
action:
|
||||
timerStatus === 'running'
|
||||
? 'timer-pause'
|
||||
: timerStatus === 'paused'
|
||||
? 'timer-resume'
|
||||
: 'timer-start',
|
||||
})
|
||||
"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path v-if="currentIndex === index && timerStatus === 'running'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||||
<path v-else d="M7 4v16l13-8z" />
|
||||
</svg>
|
||||
</n-button>
|
||||
<n-button square aria-label="Сбросить" title="Сбросить" :disabled="isBusy || currentIndex !== index" @click="emit('command', { action: 'timer-reset' })"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 4v6h6M3 10a9 9 0 1 1 0 6" />
|
||||
</svg>
|
||||
</n-button
|
||||
>
|
||||
</template>
|
||||
<template v-if="step.mode === 2 || step.mode === 4">
|
||||
<n-button
|
||||
square
|
||||
:aria-label="currentIndex === index ? mainControl.label : 'Воспроизвести'"
|
||||
:title="currentIndex === index ? mainControl.label : 'Воспроизвести'"
|
||||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||||
@click="emit('command', { action: mainControl.action, channel: 'main' })"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path v-if="currentIndex === index && mainControl.action === 'pause'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||||
<path v-else d="M7 4v16l13-8z" />
|
||||
</svg>
|
||||
</n-button
|
||||
>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Стоп"
|
||||
title="Стоп"
|
||||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||||
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5 5h14v14H5z" />
|
||||
</svg>
|
||||
</n-button
|
||||
>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Сначала"
|
||||
title="Сначала"
|
||||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||||
@click="emit('command', { action: 'replay', channel: 'main' })"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2 6M20 4v7h-7" />
|
||||
</svg>
|
||||
</n-button
|
||||
>
|
||||
<slot name="main-volume" :index="index" :active="currentIndex === index" />
|
||||
</template>
|
||||
</n-space>
|
||||
<n-button
|
||||
data-testid="show-step"
|
||||
:type="currentIndex === index ? 'primary' : 'default'"
|
||||
:disabled="isBusy || currentIndex === index"
|
||||
@click="emit('command', { action: 'step', index })"
|
||||
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
||||
>
|
||||
</div>
|
||||
<slot
|
||||
v-if="step.mode === 2 || step.mode === 4"
|
||||
name="main-controls"
|
||||
:index="index"
|
||||
:active="currentIndex === index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-space>
|
||||
</template>
|
||||
@@ -215,10 +264,62 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
|
||||
.plan-step {
|
||||
border: 1px solid var(--brand-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.plan-step-header {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-step-content {
|
||||
display: flex;
|
||||
flex: 1 1 380px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-step-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.plan-step-actions > [data-testid="show-step"] {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.plan-step-preview {
|
||||
flex: 0 0 160px;
|
||||
}
|
||||
|
||||
.plan-edit-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-edit-controls {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
flex: 1 1 320px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-step-current {
|
||||
border-color: var(--brand-text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import type { ScreenStep } from '../types/screen'
|
||||
import { formatTimer, modeLabels } from '../utils/presentation'
|
||||
|
||||
const props = defineProps<{
|
||||
step: ScreenStep
|
||||
gameName: string
|
||||
cover: string | null
|
||||
background: string | null
|
||||
}>()
|
||||
const failedImages = reactive(new Set<string>())
|
||||
function previewText(step: ScreenStep) {
|
||||
switch (step.mode) {
|
||||
case 1:
|
||||
return props.gameName
|
||||
case 3:
|
||||
return 'Успехов'
|
||||
case 5:
|
||||
return 'Спасибо за игру!'
|
||||
case 7:
|
||||
return `${step.timerLabel} ${formatTimer(step.timerDurationSeconds * 1000)}`
|
||||
case 8:
|
||||
return step.text
|
||||
default:
|
||||
return modeLabels[step.mode]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step-preview" aria-hidden="true">
|
||||
<img
|
||||
v-if="background && !failedImages.has(background)"
|
||||
:src="background"
|
||||
class="step-preview-background"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@error="failedImages.add(background!)"
|
||||
/>
|
||||
<img
|
||||
v-if="step.mode === 1 && cover && !failedImages.has(cover)"
|
||||
:src="cover"
|
||||
class="step-preview-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@error="failedImages.add(cover!)"
|
||||
/>
|
||||
<span v-if="step.mode !== 1 || !cover || failedImages.has(cover)">{{ previewText(step) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step-preview {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--brand-background);
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.step-preview img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-preview img.step-preview-cover {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.step-preview-background {
|
||||
filter: brightness(45%);
|
||||
}
|
||||
|
||||
.step-preview span {
|
||||
-webkit-box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user