up
This commit is contained in:
+77
-1
@@ -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
@@ -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
|
||||||
|
|||||||
+54
-36
@@ -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
|
|
||||||
})()`),
|
})()`),
|
||||||
'clear game selection',
|
'disconnect keeps selected game and restores connect in game screen card',
|
||||||
)
|
|
||||||
await poll(
|
|
||||||
() =>
|
|
||||||
evaluate(
|
|
||||||
`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`,
|
|
||||||
),
|
|
||||||
'server selection cleared',
|
|
||||||
)
|
)
|
||||||
await poll(
|
await poll(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
+3
-15
@@ -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 }))
|
||||||
|
|||||||
@@ -209,14 +209,15 @@ 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">
|
||||||
|
<n-card title="Игра">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-tag
|
<n-tag
|
||||||
role="status"
|
role="status"
|
||||||
data-testid="game-connection-status"
|
data-testid="game-selection-status"
|
||||||
:type="state.connection === 'connected' ? 'success' : 'error'"
|
:type="selectedKey ? 'success' : 'error'"
|
||||||
>
|
>
|
||||||
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
|
{{ selectedKey ? 'Выбрана' : 'Не выбрана' }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</template>
|
</template>
|
||||||
<div class="connection-content">
|
<div class="connection-content">
|
||||||
@@ -225,7 +226,7 @@ const gameOptions = computed(() => {
|
|||||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
|
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="state.authenticated">
|
<template v-if="state.authenticated">
|
||||||
<div class="game-selection">
|
<div v-if="!selectedKey" class="game-selection">
|
||||||
<n-select
|
<n-select
|
||||||
class="game-select"
|
class="game-select"
|
||||||
v-model:value="gameId"
|
v-model:value="gameId"
|
||||||
@@ -256,14 +257,14 @@ const gameOptions = computed(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!gameOptions.length">Нет игр.</p>
|
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
|
v-if="!selectedKey"
|
||||||
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
|
class="game-confirm"
|
||||||
>Подключиться</n-button
|
:disabled="gameId === null || isBusy || state.busy"
|
||||||
|
@click="run({ action: 'select-game', gameId: gameId! })"
|
||||||
|
>Выбрать игру</n-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<ScreenConnectionPanel :state="state" :is-busy="isBusy" @action="run" />
|
|
||||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||||
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
|
||||||
@@ -390,9 +391,35 @@ const gameOptions = computed(() => {
|
|||||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
<ScreenConnectionPanel
|
||||||
|
:state="state"
|
||||||
|
:is-busy="isBusy"
|
||||||
|
@action="run"
|
||||||
|
>
|
||||||
|
<template v-if="state.authenticated">
|
||||||
|
<n-button
|
||||||
|
v-if="state.connection !== 'connected'"
|
||||||
|
:disabled="!canConnectCurrent || isBusy || state.busy"
|
||||||
|
@click="run({ action: 'connect' })"
|
||||||
|
>Подключиться</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</ScreenConnectionPanel>
|
||||||
|
</div>
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-62
@@ -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,6 +261,16 @@ onUnmounted(() => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<n-card v-if="bridge" title="Экран">
|
<n-card v-if="bridge" title="Экран">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag
|
||||||
|
role="status"
|
||||||
|
data-testid="display-connection-status"
|
||||||
|
:type="host.displays.length ? 'success' : 'error'"
|
||||||
|
>
|
||||||
|
{{ host.displays.length ? 'Подключён' : 'Не подключён' }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
<div class="screen-grid">
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
|
||||||
<n-select
|
<n-select
|
||||||
@@ -269,23 +279,36 @@ onUnmounted(() => {
|
|||||||
placeholder="Выберите внешний дисплей"
|
placeholder="Выберите внешний дисплей"
|
||||||
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
||||||
/>
|
/>
|
||||||
<n-alert v-if="!host.displays.length" type="warning"
|
<n-space align="center">
|
||||||
>Подключите внешний дисплей и включите расширенный рабочий стол.</n-alert
|
<n-button
|
||||||
>
|
v-if="!state?.outputOpen"
|
||||||
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
|
:disabled="display === null || isBusy"
|
||||||
|
@click="run(() => bridge!.show(display!))"
|
||||||
>Открыть показ</n-button
|
>Открыть показ</n-button
|
||||||
>
|
>
|
||||||
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
|
<n-button v-else :disabled="isBusy" @click="run(() => bridge!.closeOutput())"
|
||||||
>Закрыть показ</n-button
|
>Закрыть показ</n-button
|
||||||
>
|
>
|
||||||
<template v-if="state">
|
<n-button
|
||||||
<label class="mute"
|
v-if="state"
|
||||||
>Выключить весь звук
|
square
|
||||||
<n-switch
|
:type="state.muted ? 'error' : 'default'"
|
||||||
:value="state.muted"
|
:ghost="state.muted"
|
||||||
aria-label="Выключить весь звук"
|
:disabled="isBusy"
|
||||||
@update:value="send({ action: 'mute', enabled: $event })"
|
:aria-label="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
|
||||||
/></label>
|
: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-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
|
<n-input-number
|
||||||
|
class="plan-volume"
|
||||||
:value="state.channels.main.volume"
|
:value="state.channels.main.volume"
|
||||||
:input-props="{ id: 'volume-main' }"
|
:input-props="{ id: `volume-main-${index}`, 'aria-label': 'Громкость, %' }"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="100"
|
:max="100"
|
||||||
:disabled="isBusy || isPlanSaving"
|
:disabled="!active || isBusy || isPlanSaving"
|
||||||
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
|
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
|
||||||
/>
|
/>
|
||||||
<p role="status">
|
</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] }} ·
|
{{ statusLabels[state.channels.main.status] }} ·
|
||||||
{{ state.channels.main.position.toFixed(1) }} /
|
{{ formatTimer(Math.floor(state.channels.main.position) * 1000) }} /
|
||||||
{{ state.channels.main.duration?.toFixed(1) ?? '—' }} с
|
{{ state.channels.main.duration === null ? '—' : formatTimer(state.channels.main.duration * 1000) }}
|
||||||
</p>
|
</p>
|
||||||
<n-alert v-if="state.channels.main.error" type="error">{{
|
</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>
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
@@ -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,7 +8,25 @@ function run(intent: OnlineIntent) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<p v-if="state.screenId">Экран №{{ state.screenId }}</p>
|
<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">
|
<template v-if="state.screenId">
|
||||||
<p role="status" data-testid="sound-assignment">
|
<p role="status" data-testid="sound-assignment">
|
||||||
{{
|
{{
|
||||||
@@ -30,8 +48,6 @@ function run(intent: OnlineIntent) {
|
|||||||
@click="run({ action: 'sound' })"
|
@click="run({ action: 'sound' })"
|
||||||
>Назначить звук этому экрану</n-button
|
>Назначить звук этому экрану</n-button
|
||||||
>
|
>
|
||||||
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'disconnect' })"
|
|
||||||
>Отключить управление</n-button
|
|
||||||
>
|
|
||||||
</n-space>
|
</n-space>
|
||||||
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,8 +68,17 @@ 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">
|
||||||
|
<div class="plan-step-preview">
|
||||||
|
<ScreenStepPreview
|
||||||
|
:step="step"
|
||||||
|
:game-name="gameName"
|
||||||
|
:cover="cover"
|
||||||
|
:background="background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="plan-edit-controls">
|
||||||
<n-select
|
<n-select
|
||||||
:value="step.mode"
|
:value="step.mode"
|
||||||
:options="modeOptions"
|
:options="modeOptions"
|
||||||
@@ -68,6 +86,23 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
|||||||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||||
@update:value="emit('updateStep', index, { mode: $event })"
|
@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,32 +138,39 @@ 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"
|
||||||
>
|
>
|
||||||
|
<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
|
<span
|
||||||
>{{ index + 1 }}. {{ modeLabels[step.mode]
|
>{{ modeLabels[step.mode]
|
||||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||||
><template v-if="step.mode === 7"
|
><template v-if="step.mode === 7"
|
||||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<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
|
<n-button
|
||||||
:type="currentIndex === index ? 'primary' : 'default'"
|
square
|
||||||
:disabled="isBusy || currentIndex === index"
|
:aria-label="timerLabel(index)"
|
||||||
@click="emit('command', { action: 'step', index })"
|
:title="timerLabel(index)"
|
||||||
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
:disabled="isBusy || currentIndex !== index"
|
||||||
>
|
|
||||||
<template v-if="currentIndex === index && step.mode === 7">
|
|
||||||
<n-button
|
|
||||||
:disabled="isBusy"
|
|
||||||
@click="
|
@click="
|
||||||
emit('command', {
|
emit('command', {
|
||||||
action:
|
action:
|
||||||
@@ -157,53 +182,77 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
timerStatus === 'running'
|
<path v-if="currentIndex === index && timerStatus === 'running'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||||||
? 'Пауза'
|
<path v-else d="M7 4v16l13-8z" />
|
||||||
: timerStatus === 'paused'
|
</svg>
|
||||||
? 'Продолжить'
|
|
||||||
: 'Старт'
|
|
||||||
}}
|
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
|
<n-button square aria-label="Сбросить" title="Сбросить" :disabled="isBusy || currentIndex !== index" @click="emit('command', { action: 'timer-reset' })"
|
||||||
>Сбросить</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" />
|
||||||
|
</svg>
|
||||||
|
</n-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
|
<template v-if="step.mode === 2 || step.mode === 4">
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="isBusy || !canPlayMain"
|
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' })"
|
@click="emit('command', { action: mainControl.action, channel: 'main' })"
|
||||||
>{{ mainControl.label }}</n-button
|
>
|
||||||
|
<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
|
<n-button
|
||||||
:disabled="isBusy || !canPlayMain"
|
square
|
||||||
|
aria-label="Стоп"
|
||||||
|
title="Стоп"
|
||||||
|
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||||||
@click="emit('command', { action: 'stop', channel: 'main' })"
|
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||||||
>Стоп</n-button
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M5 5h14v14H5z" />
|
||||||
|
</svg>
|
||||||
|
</n-button
|
||||||
>
|
>
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="isBusy || !canPlayMain"
|
square
|
||||||
|
aria-label="Сначала"
|
||||||
|
title="Сначала"
|
||||||
|
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||||||
@click="emit('command', { action: 'replay', channel: 'main' })"
|
@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="M20 11a8 8 0 1 0-2 6M20 4v7h-7" />
|
||||||
|
</svg>
|
||||||
|
</n-button
|
||||||
|
>
|
||||||
|
<slot name="main-volume" :index="index" :active="currentIndex === index" />
|
||||||
</template>
|
</template>
|
||||||
</n-space>
|
</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
|
<slot
|
||||||
v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"
|
v-if="step.mode === 2 || step.mode === 4"
|
||||||
name="main-controls"
|
name="main-controls"
|
||||||
|
:index="index"
|
||||||
|
:active="currentIndex === index"
|
||||||
/>
|
/>
|
||||||
</n-space>
|
</div>
|
||||||
<n-space>
|
</div>
|
||||||
<n-button
|
</div>
|
||||||
: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>
|
|
||||||
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user