This commit is contained in:
2026-10-18 18:00:12 +07:00
parent e881f10857
commit a3317dbf7a
11 changed files with 834 additions and 445 deletions
+77 -1
View File
@@ -331,6 +331,38 @@ async function fixture(t: TestContext, secure = true) {
prepare, prepare,
} }
} }
test('подтверждение игры загружает материалы без подключения экрана и сохраняет выбор', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({ action: 'select-game', gameId: 7 })
const selected = f.online.snapshot()
assert.equal(selected.currentGameId, 7)
assert.equal(selected.screenId, null)
assert.equal(selected.connection, 'disabled')
assert.ok(selected.materials.length > 0)
assert.ok(selected.materials.every((m) => m.status === 'ready'))
assert.equal(f.model.bindings.length, 0)
assert.ok(!f.requests.some((r) => r.path === '/api/screens'))
const saved = JSON.parse(await readFile(join(f.directory, 'connection', 'selection.json'), 'utf8'))
assert.equal(saved.key, f.controller.state.catalog.key)
await f.online.act({ action: 'connect' })
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.model.bindings.at(-1)!.gameID, 7)
})
test('ошибка подготовки и открытый показ не подтверждают выбор игры', async (t) => {
const f = await fixture(t)
await f.login()
f.controller.output(true)
await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }), /Закройте показ/)
f.controller.output(false)
f.model.failMedia = true
await assert.rejects(f.online.act({ action: 'select-game', gameId: 7 }))
assert.equal(f.controller.state.catalog.key, null)
assert.equal(f.online.snapshot().currentGameId, null)
assert.equal(f.model.bindings.length, 0)
})
test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => { test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => {
const f = await fixture(t) const f = await fixture(t)
assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art')
@@ -446,6 +478,50 @@ for (const gameId of [7, 8]) {
}) })
} }
for (const status of [200, 404]) {
test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => {
const f = await fixture(t)
await f.prepare()
const key = f.controller.state.catalog.key
const materials = f.online.snapshot().materials
f.model.unbindError = status === 200 ? 0 : status
await f.online.act({ action: 'disconnect' })
assert.equal(f.requests.at(-1)!.path, '/api/screens/12/unbind')
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
assert.equal(f.controller.state.catalog.key, key)
assert.deepEqual(f.online.snapshot().materials, materials)
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
assert.equal(saved.screen, null)
const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI)
t.after(() => restored.close())
await restored.initialize()
assert.equal(restored.snapshot().screenId, null)
assert.equal(restored.snapshot().currentGameId, 7)
const count = f.requests.length
await f.online.act({ action: 'disconnect' })
assert.equal(f.requests.length, count)
})
}
for (const failure of ['server', 'network'] as const) {
test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => {
const f = await fixture(t)
await f.prepare()
const saved = await readFile(join(f.directory, 'connection', 'credentials.bin'))
f.model.unbindError = failure === 'server' ? 500 : 0
f.model.offline = failure === 'network'
await assert.rejects(f.online.act({ action: 'disconnect' }))
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.online.snapshot().connection, 'waiting')
assert.deepEqual(await readFile(join(f.directory, 'connection', 'credentials.bin')), saved)
f.model.unbindError = 0
f.model.offline = false
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.snapshot().screenId, null)
})
}
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => { test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
const f = await fixture(t) const f = await fixture(t)
await f.prepare() await f.prepare()
@@ -1001,7 +1077,7 @@ test('назначение звука подтверждается сервер
const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length const unbindsBeforeDisconnect = f.requests.filter((r) => r.path.endsWith('/unbind')).length
await f.online.act({ action: 'disconnect' }) await f.online.act({ action: 'disconnect' })
assert.equal(f.online.state.soundEnabled, null) assert.equal(f.online.state.soundEnabled, null)
assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect) assert.equal(f.requests.filter((r) => r.path.endsWith('/unbind')).length, unbindsBeforeDisconnect + 1)
}) })
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => { test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
+18 -2
View File
@@ -1171,6 +1171,7 @@ export class Online {
login: 'Вход на сервер…', login: 'Вход на сервер…',
logout: 'Выход из аккаунта…', logout: 'Выход из аккаунта…',
games: 'Загрузка списка игр…', games: 'Загрузка списка игр…',
'select-game': 'Подготовка выбранной игры…',
connect: 'Подключение игры…', connect: 'Подключение игры…',
disconnect: 'Отключение игры…', disconnect: 'Отключение игры…',
download: 'Загрузка материалов игры…', download: 'Загрузка материалов игры…',
@@ -1278,10 +1279,13 @@ export class Online {
return return
case 'games': case 'games':
return this.loadGames() return this.loadGames()
case 'select-game':
case 'download': case 'download':
case 'check': { case 'check': {
const gameId = integer(data.gameId, 1) const gameId = integer(data.gameId, 1)
const key = packageKey(this.state.server, gameId) const key = packageKey(this.state.server, gameId)
if (action === 'select-game' && this.controller.state.outputOpen)
throw new Error('Закройте показ перед выбором игры')
if ( if (
action === 'download' && action === 'download' &&
key === this.controller.state.catalog.key && key === this.controller.state.catalog.key &&
@@ -1289,7 +1293,7 @@ export class Online {
) )
throw new Error('Закройте показ перед обновлением выбранной игры') throw new Error('Закройте показ перед обновлением выбранной игры')
if (!this.credentials) throw new ApiError(401) if (!this.credentials) throw new ApiError(401)
this.downloadKey = action === 'download' ? key : null this.downloadKey = action === 'check' ? null : key
this.cancellation = new AbortController() this.cancellation = new AbortController()
this.state.canCancel = true this.state.canCancel = true
try { try {
@@ -1324,7 +1328,8 @@ export class Online {
}, },
}) })
this.state.packagesRevision++ this.state.packagesRevision++
if (key === this.controller.state.catalog.key) { if (action === 'select-game' || key === this.controller.state.catalog.key) {
if (action === 'select-game') this.detach()
this.controller.selectPackage(pkg) this.controller.selectPackage(pkg)
this.plan = { this.plan = {
steps: structuredClone(this.controller.state.catalog.steps), steps: structuredClone(this.controller.state.catalog.steps),
@@ -1335,6 +1340,10 @@ export class Online {
await this.atomic(`plan-${key}.json`, this.cachedPlan) await this.atomic(`plan-${key}.json`, this.cachedPlan)
this.previous = null this.previous = null
await this.restoreResults(key) await this.restoreResults(key)
if (action === 'select-game') {
await this.saveSelection()
await this.persistSecrets()
}
} }
this.state.notice = 'Материалы проверены и готовы к показу' this.state.notice = 'Материалы проверены и готовы к показу'
} finally { } finally {
@@ -1406,6 +1415,13 @@ export class Online {
return return
} }
case 'disconnect': case 'disconnect':
if (this.screen) {
try {
await this.client().UnbindScreen({ screenID: this.screen.id })
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error
}
}
this.detach() this.detach()
await this.persistSecrets() await this.persistSecrets()
return return
+55 -37
View File
@@ -278,6 +278,8 @@ module.exports = async function onlineSmoke({
async () => !(await evaluate('window.screenBridge.onlineState()')).busy, async () => !(await evaluate('window.screenBridge.onlineState()')).busy,
'login complete', 'login complete',
) )
await evaluate(`Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim() === 'Сбросить')?.click()`)
await poll( await poll(
() => () =>
evaluate( evaluate(
@@ -288,15 +290,15 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate( evaluate(
`document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('.n-card-header__main').length === 5`, `document.querySelectorAll('main.control > .n-card').length === 3 && document.querySelectorAll('main.control > .game-grid > .n-card').length === 2 && document.querySelectorAll('.n-card-header__main').length === 6`,
), ),
'five control cards', 'six control cards',
) )
assert.deepEqual( assert.deepEqual(
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`, `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`,
), ),
['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'], ['Сервер', 'Игра', 'Экран игры', 'Экран', 'План экранов', 'План музыки'],
) )
assert.equal( assert.equal(
await evaluate(`document.querySelectorAll('details').length`), await evaluate(`document.querySelectorAll('details').length`),
@@ -319,7 +321,7 @@ module.exports = async function onlineSmoke({
]) ])
assert.equal( assert.equal(
await evaluate( await evaluate(
`Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === ${JSON.stringify(label)})`, `Array.from(document.querySelectorAll('button')).some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})`,
), ),
false, false,
'removed button: ' + label, 'removed button: ' + label,
@@ -375,12 +377,11 @@ module.exports = async function onlineSmoke({
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.includes('HTTP smoke · Завершена')).click()`, `Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.includes('HTTP smoke · Завершена')).click()`,
) )
const selectedBeforeDownload = (await state()).catalog.name const selectedBeforeDownload = (await state()).catalog.name
await poll( assert.equal((await state()).catalog.key, null, 'server selection does not prepare a game')
() => assert.equal(
evaluate( await evaluate(`!!document.querySelector('[data-testid="control-game"]')`),
`document.querySelector('[data-testid="control-game"]')?.textContent.includes(${JSON.stringify(selectedBeforeDownload)})`, false,
), 'current game stays empty until connection',
'control target stays on local package despite server game selection',
) )
assert.notEqual(selectedBeforeDownload, 'HTTP smoke') assert.notEqual(selectedBeforeDownload, 'HTTP smoke')
invalidStory = true invalidStory = true
@@ -405,6 +406,24 @@ module.exports = async function onlineSmoke({
'anywhere', 'anywhere',
) )
invalidStory = false invalidStory = false
await poll(
() => evaluate(`(() => {
const buttons = Array.from(document.querySelectorAll('button'))
const select = buttons.find(b => b.textContent.trim() === 'Выбрать игру')
const connect = buttons.find(b => b.textContent.trim() === 'Подключиться')
if (!select || select.disabled || !connect?.disabled) return false
select.click(); return true
})()`),
'confirm selected game before screen connection',
)
await poll(
async () => {
const online = await evaluate('window.screenBridge.onlineState()')
return !online.busy && online.currentGameId === 6 && online.screenId === null
&& online.materials.every(m => m.status === 'ready')
},
'game prepared without screen connection',
)
await poll( await poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
@@ -434,8 +453,15 @@ module.exports = async function onlineSmoke({
) )
await checkBadges(true, true) await checkBadges(true, true)
await poll( await poll(
() => evaluate(`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('HTTP smoke')`), () => evaluate(`(() => {
'current game automatically selected', const content = document.querySelector('.connection-content')
const buttons = Array.from(document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card')?.querySelectorAll('button') ?? [])
return !content?.querySelector('.game-selection')
&& !buttons.some(b => b.textContent.trim() === 'Подключиться')
&& buttons.some(b => b.textContent.trim() === 'Отключиться'
&& b.closest('.n-space')?.textContent.includes('Экран №'))
})()`),
'selected game hides selector and connect button, disconnect follows screen name',
) )
assert.equal((await state()).outputOpen, false) assert.equal((await state()).outputOpen, false)
await evaluate(`window.screenBridge.show(${displayId})`) await evaluate(`window.screenBridge.show(${displayId})`)
@@ -447,12 +473,12 @@ module.exports = async function onlineSmoke({
await evaluate(`(() => { await evaluate(`(() => {
const assignment = document.querySelector('[data-testid="sound-assignment"]') const assignment = document.querySelector('[data-testid="sound-assignment"]')
const card = assignment?.closest('.n-card') const card = assignment?.closest('.n-card')
return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Игра' return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран игры'
&& ['Назначить звук этому экрану', 'Отключить управление'].every(label => && ['Назначить звук этому экрану', 'Отключиться'].every(label =>
!Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label))
})()`), })()`),
true, true,
'server screen controls belong to game card', 'server screen controls belong to game screen card',
) )
await poll( await poll(
() => () =>
@@ -602,7 +628,7 @@ module.exports = async function onlineSmoke({
() => () =>
evaluate(`(() => { evaluate(`(() => {
const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button')) const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button'))
.find(b => b.textContent.trim() === ${JSON.stringify(label)}) .find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})
if (!button || button.disabled) return false if (!button || button.disabled) return false
button.click(); return true button.click(); return true
})()`), })()`),
@@ -653,14 +679,14 @@ module.exports = async function onlineSmoke({
assert.equal((await state()).scene.revision, beforeSaveScene) assert.equal((await state()).scene.revision, beforeSaveScene)
assert.equal(plan.steps[1].text, 'Текст из редактора') assert.equal(plan.steps[1].text, 'Текст из редактора')
assert.equal(plan.steps[2].timerDurationSeconds, 60) assert.equal(plan.steps[2].timerDurationSeconds, 60)
await evaluate(`document.querySelector('[data-step-index="1"] button').click()`) await evaluate(`document.querySelector('[data-step-index="1"] [data-testid="show-step"]').click()`)
await poll( await poll(
async () => async () =>
(await state()).scene.text === 'Текст из редактора' && (await state()).scene.text === 'Текст из редактора' &&
(await state()).scene.stepIndex === 1, (await state()).scene.stepIndex === 1,
'show text step', 'show text step',
) )
await evaluate(`document.querySelector('[data-step-index="2"] button').click()`) await evaluate(`document.querySelector('[data-step-index="2"] [data-testid="show-step"]').click()`)
await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step') await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step')
assert.equal((await state()).timer.status, 'stopped') assert.equal((await state()).timer.status, 'stopped')
assert.equal((await state()).channels.main.desired, 'stopped') assert.equal((await state()).channels.main.desired, 'stopped')
@@ -704,7 +730,7 @@ module.exports = async function onlineSmoke({
poll( poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}) const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})
if (!button || button.disabled) return false if (!button || button.disabled) return false
button.click(); return true button.click(); return true
})()`), })()`),
@@ -823,25 +849,17 @@ module.exports = async function onlineSmoke({
assert.equal(afterOrder.desired, 'playing') assert.equal(afterOrder.desired, 'playing')
assert.ok(afterOrder.position >= beforeOrder.position) assert.ok(afterOrder.position >= beforeOrder.position)
console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS')
await act({ action: 'disconnect' }) await evaluate(`Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim() === 'Отключиться').click()`)
await poll( await poll(
() => () => evaluate(`(() => {
evaluate(`(() => { const card = document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card')
const select = document.querySelector('.connection-content .n-base-selection') return card?.textContent.includes('Не подключено')
select?.dispatchEvent(new MouseEvent('mouseenter')) && card.textContent.includes('Подключиться')
const clear = select?.querySelector('.n-base-clear__clear') && !card.textContent.includes('Отключиться')
if (!clear) return false && !document.querySelector('.game-selection')
clear.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })()`),
clear.click(); return true 'disconnect keeps selected game and restores connect in game screen card',
})()`),
'clear game selection',
)
await poll(
() =>
evaluate(
`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`,
),
'server selection cleared',
) )
await poll( await poll(
() => () =>
+3 -15
View File
@@ -407,12 +407,12 @@ app.whenReady().then(async () => {
) )
if (index === 0) if (index === 0)
assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320) assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320)
else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320) assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320)
if (index === 1 || index === 3) { if (index === 1 || index === 3) {
const clickMedia = (label) => const clickMedia = (label) =>
evaluate(`(() => { evaluate(`(() => {
const row = document.querySelector('[data-step-index="${index}"]') const row = document.querySelector('[data-step-index="${index}"]')
const button = Array.from(row.querySelectorAll('button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}) const button = Array.from(row.querySelectorAll('button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})
if (!button || button.disabled) throw new Error('Main control unavailable') if (!button || button.disabled) throw new Error('Main control unavailable')
button.click() button.click()
})()`) })()`)
@@ -427,19 +427,7 @@ app.whenReady().then(async () => {
'plan media paused', 'plan media paused',
) )
await evaluate(`(() => { await evaluate(`(() => {
const input = document.querySelector('#position-main') const input = document.querySelector('.plan-step-current input[id^="volume-main-"]')
input.focus(); input.value = '1'
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
input.blur()
})()`)
await clickMedia('Перемотать')
await poll(
async () => Math.abs((await state()).channels.main.position - 1) < 0.1,
'plan media seek',
)
await evaluate(`(() => {
const input = document.querySelector('#volume-main')
input.focus(); input.value = '42' input.focus(); input.value = '42'
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+205 -177
View File
@@ -209,190 +209,217 @@ const gameOptions = computed(() => {
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert> <n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
</div> </div>
</n-card> </n-card>
<n-card v-if="bridge && isReady" title="Игра"> <div v-if="bridge && isReady" class="game-grid">
<template #header-extra> <n-card title="Игра">
<n-tag <template #header-extra>
role="status" <n-tag
data-testid="game-connection-status" role="status"
:type="state.connection === 'connected' ? 'success' : 'error'" data-testid="game-selection-status"
> :type="selectedKey ? 'success' : 'error'"
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }} >
</n-tag> {{ selectedKey ? 'Выбрана' : 'Не выбрана' }}
</template> </n-tag>
<div class="connection-content"> </template>
<div v-if="selectedKey" class="current-game"> <div class="connection-content">
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span> <div v-if="selectedKey" class="current-game">
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button> <span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
</div> <n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
<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> </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 <n-button
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy" v-if="state.connection !== 'connected'"
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })" :disabled="!canConnectCurrent || isBusy || state.busy"
@click="run({ action: 'connect' })"
>Подключиться</n-button >Подключиться</n-button
> >
</template> </template>
<ScreenConnectionPanel :state="state" :is-busy="isBusy" @action="run" /> </ScreenConnectionPanel>
<p v-if="state.preparation.active" role="status" data-testid="materials-progress"> </div>
Материалы: {{ 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>
</template> </template>
<style scoped> <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 { .materials-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -512,7 +539,8 @@ const gameOptions = computed(() => {
align-self: flex-start; align-self: flex-start;
} }
.connection-content > .auth-action { .connection-content > .auth-action,
.connection-content > .game-confirm {
align-self: flex-end; align-self: flex-end;
} }
+135 -81
View File
@@ -4,9 +4,11 @@ import {
NButton, NButton,
NCard, NCard,
NInputNumber, NInputNumber,
NProgress,
NSelect, NSelect,
NSpace, NSpace,
NSwitch, NSwitch,
NTag,
useMessage, useMessage,
useDialog, useDialog,
} from 'naive-ui' } from 'naive-ui'
@@ -26,8 +28,8 @@ import ConnectionPanel from './ConnectionPanel.vue'
import MusicPlanPanel from './MusicPlanPanel.vue' import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection' import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue' import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { useScreenTimer } from '../composables/useScreenTimer'
import { playbackControl } from '../utils/playbackControl' import { playbackControl } from '../utils/playbackControl'
import { formatTimer } from '../utils/presentation'
const { const {
state: onlineState, state: onlineState,
@@ -46,6 +48,11 @@ const originalPlan = ref('')
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value) const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
const bridge = window.screenBridge const bridge = window.screenBridge
const state = ref<State | null>(null) 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>({ const host = ref<HostInfo>({
displays: [], displays: [],
notice: '', notice: '',
@@ -55,7 +62,6 @@ const host = ref<HostInfo>({
const display = ref<number | null>(null) const display = ref<number | null>(null)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 }) const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false) const isBusy = ref(false)
const timerDisplay = useScreenTimer(() => state.value)
const preview = ref<PreviewFrame | null>(null) const preview = ref<PreviewFrame | null>(null)
const previewURL = computed(() => const previewURL = computed(() =>
preview.value?.session === state.value?.session && preview.value?.session === state.value?.session &&
@@ -63,12 +69,6 @@ const previewURL = computed(() =>
? preview.value?.dataURL ? preview.value?.dataURL
: null, : null,
) )
const timerLabels = {
stopped: 'Не запущен',
running: 'Идёт',
paused: 'Пауза',
finished: 'Завершён',
}
let previewTimer: ReturnType<typeof setInterval> | undefined let previewTimer: ReturnType<typeof setInterval> | undefined
let previewBusy = false let previewBusy = false
async function refreshPreview() { async function refreshPreview() {
@@ -261,31 +261,54 @@ onUnmounted(() => {
" "
/> />
<n-card v-if="bridge" title="Экран"> <n-card v-if="bridge" title="Экран">
<n-space vertical> <template #header-extra>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert> <n-tag
<n-select role="status"
v-model:value="display" data-testid="display-connection-status"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))" :type="host.displays.length ? 'success' : 'error'"
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!))" {{ host.displays.length ? 'Подключён' : 'Не подключён' }}
>Открыть показ</n-button </n-tag>
> </template>
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())" <div class="screen-grid">
>Закрыть показ</n-button <n-space vertical>
> <n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<template v-if="state"> <n-select
<label class="mute" v-model:value="display"
>Выключить весь звук :options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
<n-switch placeholder="Выберите внешний дисплей"
:value="state.muted" :input-props="{ 'aria-label': 'Дисплей показа' }"
aria-label="Выключить весь звук" />
@update:value="send({ action: 'mute', enabled: $event })" <n-space align="center">
/></label> <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 <img
v-if="previewURL" v-if="previewURL"
class="preview" class="preview"
@@ -302,14 +325,17 @@ onUnmounted(() => {
: 'Предпросмотр недоступен' : 'Предпросмотр недоступен'
}} }}
</div> </div>
</template> </div>
</n-space> </div>
</n-card> </n-card>
<template v-if="state"> <template v-if="state">
<section class="presentation-grid"> <section class="presentation-grid">
<n-card title="План экранов"> <n-card title="План экранов">
<n-space vertical> <n-space vertical>
<ScreenPlanPanel <ScreenPlanPanel
:game-name="state.catalog.name"
:cover="state.catalog.cover"
:background="state.catalog.background"
:steps="state.catalog.steps" :steps="state.catalog.steps"
:current-index="state.scene.stepIndex" :current-index="state.scene.stepIndex"
:draft="planDraft" :draft="planDraft"
@@ -335,51 +361,45 @@ onUnmounted(() => {
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)" @update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
@command="send" @command="send"
> >
<template #main-controls> <template #main-volume="{ index, active }">
<label for="position-main">Позиция, секунды</label> <div class="plan-volume-control">
<n-input-number <label :for="`volume-main-${index}`" title="Громкость, %">
v-model:value="positions.main" <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
:input-props="{ id: 'position-main' }" <path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
:min="0" </svg>
:max="state.channels.main.duration ?? undefined" </label>
/> <n-input-number
<n-button class="plan-volume"
:disabled=" :value="state.channels.main.volume"
!state.outputOpen || !state.channels.main.source || isBusy || isPlanSaving :input-props="{ id: `volume-main-${index}`, 'aria-label': 'Громкость, %' }"
" :min="0"
@click="send({ action: 'seek', channel: 'main', position: positions.main ?? 0 })" :max="100"
>Перемотать</n-button :disabled="!active || isBusy || isPlanSaving"
> @update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
<label for="volume-main">Громкость, %</label> />
<n-input-number </div>
:value="state.channels.main.volume" </template>
:input-props="{ id: 'volume-main' }" <template #main-controls="{ active }">
:min="0" <div class="playback-progress-row">
:max="100" <n-progress
:disabled="isBusy || isPlanSaving" class="playback-progress-bar"
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })" type="line"
/> :percentage="active ? mainProgress : 0"
<p role="status"> :show-indicator="false"
{{ statusLabels[state.channels.main.status] }} · aria-label="Прогресс воспроизведения"
{{ state.channels.main.position.toFixed(1) }} / />
{{ state.channels.main.duration?.toFixed(1) ?? '—' }} с <p v-if="active" role="status" class="playback-progress">
</p> {{ statusLabels[state.channels.main.status] }} ·
<n-alert v-if="state.channels.main.error" type="error">{{ {{ 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 state.channels.main.error
}}</n-alert> }}</n-alert>
</template> </template>
</ScreenPlanPanel> </ScreenPlanPanel>
<p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</p> <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-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
<n-button <n-button
v-if="state.scene.status === 'error'" v-if="state.scene.status === 'error'"
@@ -504,10 +524,21 @@ onUnmounted(() => {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); 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 { .preview {
background: var(--brand-background); background: var(--brand-background);
display: block; display: block;
max-height: 50vh; max-height: 25vh;
object-fit: contain; object-fit: contain;
width: 100%; width: 100%;
} }
@@ -520,7 +551,35 @@ onUnmounted(() => {
justify-content: center; 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; font-variant-numeric: tabular-nums;
} }
@@ -528,9 +587,4 @@ p {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.mute {
align-items: center;
display: flex;
gap: 12px;
}
</style> </style>
+1 -1
View File
@@ -114,7 +114,7 @@ onUnmounted(() => {
<template> <template>
<main ref="surface" class="output" :data-mode="state?.scene.mode"> <main ref="surface" class="output" :data-mode="state?.scene.mode">
<img <img
v-if="state?.catalog.background && state.scene.mode !== 1" v-if="state?.catalog.background"
:key="`${state.session}:${state.scene.revision}:background`" :key="`${state.session}:${state.scene.revision}:background`"
class="background" class="background"
:class="{ failed: imageError }" :class="{ failed: imageError }"
+43 -27
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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' import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean }>() defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>() const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
@@ -8,30 +8,46 @@ function run(intent: OnlineIntent) {
} }
</script> </script>
<template> <template>
<p v-if="state.screenId">Экран №{{ state.screenId }}</p> <n-card title="Экран игры">
<template v-if="state.screenId"> <template #header-extra>
<p role="status" data-testid="sound-assignment"> <n-tag
{{ role="status"
state.soundEnabled === null data-testid="game-connection-status"
? 'Назначение звука пока неизвестно' :type="state.connection === 'connected' ? 'success' : 'error'"
: state.soundEnabled >
? 'Экран назначен для звука' {{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
: 'Экран не назначен для звука' </n-tag>
}} </template>
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'"> <slot />
· Последнее состояние <n-space v-if="state.screenId" align="center">
</template> <span>Экран №{{ state.screenId }}</span>
</p> <n-button
</template> :disabled="isBusy || state.busy"
<n-space v-if="state.screenId"> @click="run({ action: 'disconnect' })"
<n-button >Отключиться</n-button
v-if="state.soundEnabled !== true" >
:disabled="isBusy || state.busy || !state.authenticated" </n-space>
@click="run({ action: 'sound' })" <template v-if="state.screenId">
>Назначить звук этому экрану</n-button <p role="status" data-testid="sound-assignment">
> {{
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })" state.soundEnabled === null
>Отключить управление</n-button ? 'Назначение звука пока неизвестно'
> : state.soundEnabled
</n-space> ? 'Экран назначен для звука'
: 'Экран не назначен для звука'
}}
<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> </template>
+204 -103
View File
@@ -4,8 +4,12 @@ import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl' import { playbackControl } from '../utils/playbackControl'
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen' import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { modeLabels } from '../utils/presentation' import { modeLabels } from '../utils/presentation'
import ScreenStepPreview from './ScreenStepPreview.vue'
const props = defineProps<{ const props = defineProps<{
gameName: string
cover: string | null
background: string | null
steps: ScreenStep[] steps: ScreenStep[]
currentIndex: number | null currentIndex: number | null
draft: ScreenStep[] draft: ScreenStep[]
@@ -36,6 +40,11 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode, value: Number(mode) as ScreenMode,
label, label,
})) }))
function timerLabel(index: number) {
if (props.currentIndex !== index) return 'Старт'
if (props.timerStatus === 'running') return 'Пауза'
return props.timerStatus === 'paused' ? 'Продолжить' : 'Старт'
}
</script> </script>
<template> <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> <n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p> <p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
<template v-if="isEditing"> <template v-if="isEditing">
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step"> <div v-for="(step, index) in draft" :key="index" class="plan-step">
<span>Шаг {{ index + 1 }}</span> <div class="plan-edit-header">
<n-select <div class="plan-step-preview">
:value="step.mode" <ScreenStepPreview
:options="modeOptions" :step="step"
:disabled="isBusy" :game-name="gameName"
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }" :cover="cover"
@update:value="emit('updateStep', index, { mode: $event })" :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 <n-input
v-if="step.mode === 8" v-if="step.mode === 8"
:value="step.text" :value="step.text"
@@ -95,24 +130,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })" @update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
/> />
</template> </template>
<n-space> </div>
<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>
<n-space> <n-space>
<n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button> <n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
<n-button :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</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>
<template v-else> <template v-else>
<p v-if="!steps.length">План экранов пуст.</p> <p v-if="!steps.length">План экранов пуст.</p>
<n-space <div
v-for="(step, index) in steps" v-for="(step, index) in steps"
:key="index" :key="index"
vertical
class="plan-step" class="plan-step"
:class="{ 'plan-step-current': currentIndex === index }" :class="{ 'plan-step-current': currentIndex === index }"
:data-step-index="index" :data-step-index="index"
:aria-current="currentIndex === index ? 'step' : undefined" :aria-current="currentIndex === index ? 'step' : undefined"
> >
<span <div class="plan-step-header">
>{{ index + 1 }}. {{ modeLabels[step.mode] <div class="plan-step-preview">
}}<template v-if="step.mode === 8">: {{ step.text }}</template <ScreenStepPreview
><template v-if="step.mode === 7" :step="step"
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template :game-name="gameName"
></span :cover="cover"
> :background="background"
<n-space> />
<n-button </div>
:type="currentIndex === index ? 'primary' : 'default'" <div class="plan-step-content">
:disabled="isBusy || currentIndex === index" <span
@click="emit('command', { action: 'step', index })" >{{ modeLabels[step.mode]
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button }}<template v-if="step.mode === 8">: {{ step.text }}</template
> ><template v-if="step.mode === 7"
<template v-if="currentIndex === index && step.mode === 7"> >: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
<n-button ></span
:disabled="isBusy"
@click="
emit('command', {
action:
timerStatus === 'running'
? 'timer-pause'
: timerStatus === 'paused'
? 'timer-resume'
: 'timer-start',
})
"
> >
{{ <div class="plan-step-actions">
timerStatus === 'running' <n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false">
? 'Пауза' <template v-if="step.mode === 7">
: timerStatus === 'paused' <n-button
? 'Продолжить' square
: 'Старт' :aria-label="timerLabel(index)"
}} :title="timerLabel(index)"
</n-button> :disabled="isBusy || currentIndex !== index"
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })" @click="
>Сбросить</n-button emit('command', {
> action:
</template> timerStatus === 'running'
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"> ? 'timer-pause'
<n-button : timerStatus === 'paused'
:disabled="isBusy || !canPlayMain" ? 'timer-resume'
@click="emit('command', { action: mainControl.action, channel: 'main' })" : 'timer-start',
>{{ mainControl.label }}</n-button })
> "
<n-button >
:disabled="isBusy || !canPlayMain" <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@click="emit('command', { action: 'stop', channel: 'main' })" <path v-if="currentIndex === index && timerStatus === 'running'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
>Стоп</n-button <path v-else d="M7 4v16l13-8z" />
> </svg>
<n-button </n-button>
:disabled="isBusy || !canPlayMain" <n-button square aria-label="Сбросить" title="Сбросить" :disabled="isBusy || currentIndex !== index" @click="emit('command', { action: 'timer-reset' })"
@click="emit('command', { action: 'replay', channel: 'main' })" >
>Сначала</n-button <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" />
</template> </svg>
</n-space> </n-button
<slot >
v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)" </template>
name="main-controls" <template v-if="step.mode === 2 || step.mode === 4">
/> <n-button
</n-space> square
<n-space> :aria-label="currentIndex === index ? mainControl.label : 'Воспроизвести'"
<n-button :title="currentIndex === index ? mainControl.label : 'Воспроизвести'"
:disabled="isBusy || currentIndex === null || currentIndex <= 0" :disabled="isBusy || currentIndex !== index || !canPlayMain"
@click="emit('command', { action: 'step', index: (currentIndex ?? 0) - 1 })" @click="emit('command', { action: mainControl.action, channel: 'main' })"
>Предыдущий шаг</n-button >
> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<n-button <path v-if="currentIndex === index && mainControl.action === 'pause'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
:disabled="isBusy || !steps.length || (currentIndex ?? -1) >= steps.length - 1" <path v-else d="M7 4v16l13-8z" />
@click="emit('command', { action: 'step', index: (currentIndex ?? -1) + 1 })" </svg>
>Следующий шаг</n-button </n-button
> >
</n-space> <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> </template>
</n-space> </n-space>
</template> </template>
@@ -215,10 +264,62 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
.plan-step { .plan-step {
border: 1px solid var(--brand-background); border: 1px solid var(--brand-background);
display: flex;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
padding: 12px; 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 { .plan-step-current {
border-color: var(--brand-text); border-color: var(--brand-text);
} }
+92
View File
@@ -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>
+1 -1
View File
@@ -30,7 +30,7 @@ export type OnlineIntent =
| { action: 'retry-materials' } | { action: 'retry-materials' }
| { action: 'connect'; gameId?: number } | { action: 'connect'; gameId?: number }
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' } | { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' }
| { action: 'download' | 'check'; gameId: number } | { action: 'select-game' | 'download' | 'check'; gameId: number }
| { action: 'remove'; key: string } | { action: 'remove'; key: string }
export interface OnlineState { export interface OnlineState {
server: string server: string