up
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { AudioOutput } from './audio-output.js'
|
||||||
|
import { parseWindowPreferences } from './window-settings.js'
|
||||||
|
|
||||||
|
const devices = [{ id: 'hdmi', label: 'HDMI' }, { id: 'usb', label: 'USB' }]
|
||||||
|
test('выбор сохраняется, применяется до запуска проигрывания и проверяет устройство', async () => {
|
||||||
|
const applied: string[] = []
|
||||||
|
const saved: string[] = []
|
||||||
|
const output = new AudioOutput('', async () => devices, async (id) => { applied.push(id) }, (id) => { saved.push(id) })
|
||||||
|
await output.refresh()
|
||||||
|
await output.select('hdmi')
|
||||||
|
await output.prepare(async (id) => { applied.push(`prepare:${id}`) })
|
||||||
|
assert.deepEqual(applied, ['hdmi', 'prepare:hdmi'])
|
||||||
|
assert.deepEqual(saved, ['hdmi'])
|
||||||
|
assert.equal(output.selected, 'hdmi')
|
||||||
|
await assert.rejects(output.select('missing'), /недоступно/)
|
||||||
|
await assert.rejects(output.select({ id: 'usb' }), /недоступно/)
|
||||||
|
assert.equal(output.selected, 'hdmi')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ошибка переключения или сохранения возвращает прежний выход', async () => {
|
||||||
|
for (const failure of ['apply', 'save']) {
|
||||||
|
const applied: string[] = []
|
||||||
|
const output = new AudioOutput('hdmi', async () => devices, async (id) => {
|
||||||
|
applied.push(id)
|
||||||
|
if (id === 'usb' && failure === 'apply') throw new Error('switch failed')
|
||||||
|
}, () => { if (failure === 'save') throw new Error('save failed') })
|
||||||
|
await output.refresh()
|
||||||
|
await assert.rejects(output.select('usb'), /failed/)
|
||||||
|
assert.equal(output.selected, 'hdmi')
|
||||||
|
assert.deepEqual(applied, ['usb', 'hdmi'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('отключение устройства сбрасывает выбор, повторное подключение не меняет его', async () => {
|
||||||
|
let available = devices
|
||||||
|
const saved: string[] = []
|
||||||
|
const output = new AudioOutput('hdmi', async () => available, async () => {}, (id) => { saved.push(id) })
|
||||||
|
await output.refresh()
|
||||||
|
assert.equal(output.selected, 'hdmi')
|
||||||
|
available = []
|
||||||
|
await output.refresh()
|
||||||
|
assert.equal(output.selected, '')
|
||||||
|
assert.match(output.notice, /отключено/)
|
||||||
|
assert.deepEqual(saved, [''])
|
||||||
|
available = devices
|
||||||
|
await output.refresh()
|
||||||
|
assert.equal(output.selected, '')
|
||||||
|
await output.select('usb')
|
||||||
|
assert.equal(output.notice, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('быстрые переключения и подготовка нового окна выполняются по порядку', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
let release: () => void = () => {}
|
||||||
|
const held = new Promise<void>((resolve) => { release = resolve })
|
||||||
|
const output = new AudioOutput('', async () => devices, async (id) => {
|
||||||
|
calls.push(id)
|
||||||
|
if (id === 'hdmi') await held
|
||||||
|
}, () => {})
|
||||||
|
await output.refresh()
|
||||||
|
const first = output.select('hdmi')
|
||||||
|
const second = output.select('usb')
|
||||||
|
const prepare = output.prepare(async (id) => { calls.push(`prepare:${id}`) })
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.deepEqual(calls, ['hdmi'])
|
||||||
|
release()
|
||||||
|
await Promise.all([first, second, prepare])
|
||||||
|
assert.deepEqual(calls, ['hdmi', 'usb', 'prepare:usb'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('старые настройки совместимы, выбранный аудиовыход читается и валидируется', () => {
|
||||||
|
const old = { version: 1, control: null, display: null }
|
||||||
|
assert.equal(parseWindowPreferences(old).audioOutput, undefined)
|
||||||
|
assert.equal(parseWindowPreferences({ ...old, audioOutput: 'hdmi' }).audioOutput, 'hdmi')
|
||||||
|
assert.throws(() => parseWindowPreferences({ ...old, audioOutput: 123 }))
|
||||||
|
assert.throws(() => parseWindowPreferences({ ...old, audioOutput: 'a'.repeat(1025) }))
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { AudioOutputInfo } from '../src/types/screen.js'
|
||||||
|
|
||||||
|
export class AudioOutput {
|
||||||
|
devices: AudioOutputInfo[] = []
|
||||||
|
notice = ''
|
||||||
|
private pending: Promise<unknown> = Promise.resolve()
|
||||||
|
constructor(
|
||||||
|
public selected: string,
|
||||||
|
private enumerate: () => Promise<AudioOutputInfo[]>,
|
||||||
|
private apply: (id: string) => Promise<void>,
|
||||||
|
private save: (id: string) => void,
|
||||||
|
) {}
|
||||||
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.pending.then(operation)
|
||||||
|
this.pending = result.catch(() => {})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
refresh() {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
this.devices = await this.enumerate()
|
||||||
|
if (this.selected && !this.devices.some((device) => device.id === this.selected)) {
|
||||||
|
await this.apply('')
|
||||||
|
this.selected = ''
|
||||||
|
this.notice = 'Устройство звука отключено. Используется устройство по умолчанию в системе.'
|
||||||
|
this.save('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select(id: unknown) {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
if (typeof id !== 'string' || (id !== '' && !this.devices.some((d) => d.id === id)))
|
||||||
|
throw new Error('Устройство вывода звука недоступно')
|
||||||
|
const previous = this.selected
|
||||||
|
try {
|
||||||
|
await this.apply(id)
|
||||||
|
this.save(id)
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await this.apply(previous)
|
||||||
|
} catch {
|
||||||
|
this.notice = 'Не удалось восстановить устройство звука. Выберите устройство заново.'
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
this.selected = id
|
||||||
|
this.notice = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prepare(apply: (id: string) => Promise<void>) {
|
||||||
|
return this.enqueue(() => apply(this.selected))
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-5
@@ -19,6 +19,7 @@ import { Readable } from 'node:stream'
|
|||||||
import { DiagnosticLog } from './diagnostics.js'
|
import { DiagnosticLog } from './diagnostics.js'
|
||||||
import { Controller, parseCommand } from './controller.js'
|
import { Controller, parseCommand } from './controller.js'
|
||||||
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
|
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
|
||||||
|
import { AudioOutput } from './audio-output.js'
|
||||||
import { parseRange } from './range.js'
|
import { parseRange } from './range.js'
|
||||||
import { Online } from './online.js'
|
import { Online } from './online.js'
|
||||||
import type { OnlineState } from '../src/types/online.js'
|
import type { OnlineState } from '../src/types/online.js'
|
||||||
@@ -40,6 +41,8 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
])
|
])
|
||||||
let control: BrowserWindow | null = null
|
let control: BrowserWindow | null = null
|
||||||
let output: BrowserWindow | null = null
|
let output: BrowserWindow | null = null
|
||||||
|
let audioDevicesWindow: BrowserWindow | null = null
|
||||||
|
let audioOutput: AudioOutput
|
||||||
let closingWindow: BrowserWindow | null = null
|
let closingWindow: BrowserWindow | null = null
|
||||||
let openingOutput = false
|
let openingOutput = false
|
||||||
let outputEpoch = 0
|
let outputEpoch = 0
|
||||||
@@ -70,6 +73,9 @@ const rendererRoot = () => join(app.getAppPath(), 'dist')
|
|||||||
function host(): HostInfo {
|
function host(): HostInfo {
|
||||||
const primary = screen.getPrimaryDisplay().id
|
const primary = screen.getPrimaryDisplay().id
|
||||||
return {
|
return {
|
||||||
|
audioOutputs: audioOutput?.devices ?? [],
|
||||||
|
audioOutputId: audioOutput?.selected ?? '',
|
||||||
|
audioOutputNotice: audioOutput?.notice ?? '',
|
||||||
notice: hostNotice,
|
notice: hostNotice,
|
||||||
sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker),
|
sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker),
|
||||||
preferredDisplayId: preferredDisplayId(
|
preferredDisplayId: preferredDisplayId(
|
||||||
@@ -90,7 +96,7 @@ function windowOptions(isOutput = false) {
|
|||||||
return {
|
return {
|
||||||
backgroundColor: '#111111',
|
backgroundColor: '#111111',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
...(isOutput ? { partition: 'screen-output' } : {}),
|
...(isOutput ? { partition: 'persist:screen-output' } : {}),
|
||||||
preload: join(__dirname, 'preload.js'),
|
preload: join(__dirname, 'preload.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -98,6 +104,15 @@ function windowOptions(isOutput = false) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function setAudioSink(window: BrowserWindow, id: string) {
|
||||||
|
await window.webContents.executeJavaScript(`(async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from(document.querySelectorAll('audio,video')).map(media => media.setSinkId(${JSON.stringify(id)}))
|
||||||
|
);
|
||||||
|
const failed = results.find(result => result.status === 'rejected');
|
||||||
|
if (failed) throw failed.reason;
|
||||||
|
})()`)
|
||||||
|
}
|
||||||
function secure(window: BrowserWindow) {
|
function secure(window: BrowserWindow) {
|
||||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
window.webContents.on('will-navigate', (event) => event.preventDefault())
|
window.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||||
@@ -462,12 +477,60 @@ app
|
|||||||
return new Response(null, { status: 404 })
|
return new Response(null, { status: 404 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Разные сессии изолируют renderer показа от пульта, протокол и запреты одинаковы.
|
// Выбор выхода разрешён только renderer показа; камера и микрофон остаются запрещены.
|
||||||
for (const target of [session.defaultSession, session.fromPartition('screen-output')]) {
|
for (const target of [session.defaultSession, session.fromPartition('persist:screen-output')]) {
|
||||||
target.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
|
const allowAudioOutput = (contents: Electron.WebContents | null, permission: string) =>
|
||||||
target.setPermissionCheckHandler(() => false)
|
permission === 'speaker-selection' &&
|
||||||
|
!!contents &&
|
||||||
|
[audioDevicesWindow?.webContents, output?.webContents].includes(contents) &&
|
||||||
|
contents.getURL().startsWith('screen-app://app/index.html?')
|
||||||
|
target.setPermissionRequestHandler((contents, permission, callback, details) =>
|
||||||
|
callback(details.isMainFrame && allowAudioOutput(contents, permission)),
|
||||||
|
)
|
||||||
|
target.setPermissionCheckHandler((contents, permission, origin, details) =>
|
||||||
|
details.isMainFrame === true &&
|
||||||
|
(origin === 'screen-app://app' || origin === 'screen-app://app/') &&
|
||||||
|
allowAudioOutput(contents, permission),
|
||||||
|
)
|
||||||
target.protocol.handle('screen-app', handleAppRequest)
|
target.protocol.handle('screen-app', handleAppRequest)
|
||||||
}
|
}
|
||||||
|
audioDevicesWindow = new BrowserWindow({ ...windowOptions(true), show: false })
|
||||||
|
secure(audioDevicesWindow)
|
||||||
|
await audioDevicesWindow.loadURL('screen-app://app/index.html?audio-devices=1')
|
||||||
|
await audioDevicesWindow.webContents.executeJavaScript(
|
||||||
|
"document.body.appendChild(document.createElement('audio'))",
|
||||||
|
)
|
||||||
|
audioOutput = new AudioOutput(
|
||||||
|
settings.value.audioOutput ?? '',
|
||||||
|
() => audioDevicesWindow!.webContents.executeJavaScript(`navigator.mediaDevices.enumerateDevices().then(
|
||||||
|
devices => devices.filter(d => d.kind === 'audiooutput' && d.deviceId && d.deviceId !== 'default' && d.deviceId !== 'communications')
|
||||||
|
.map((d, index) => ({ id: d.deviceId, label: d.label || 'Устройство звука ' + (index + 1) }))
|
||||||
|
)`),
|
||||||
|
async (id) => {
|
||||||
|
await setAudioSink(audioDevicesWindow!, id)
|
||||||
|
const current = output
|
||||||
|
if (current && !current.isDestroyed() && !current.webContents.isLoadingMainFrame())
|
||||||
|
await setAudioSink(current, id)
|
||||||
|
},
|
||||||
|
(id) => {
|
||||||
|
const previous = settings.value.audioOutput
|
||||||
|
settings.value.audioOutput = id
|
||||||
|
try {
|
||||||
|
settings.save()
|
||||||
|
} catch (error) {
|
||||||
|
settings.value.audioOutput = previous
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const refreshAudioOutputs = () => audioOutput.refresh().catch(() => {
|
||||||
|
audioOutput.notice = 'Не удалось обновить список устройств звука.'
|
||||||
|
})
|
||||||
|
ipcMain.on('audio-devices-changed', (event) => {
|
||||||
|
if (event.sender === audioDevicesWindow?.webContents && event.senderFrame === event.sender.mainFrame)
|
||||||
|
void refreshAudioOutputs()
|
||||||
|
})
|
||||||
|
await refreshAudioOutputs()
|
||||||
control = new BrowserWindow({
|
control = new BrowserWindow({
|
||||||
...windowOptions(),
|
...windowOptions(),
|
||||||
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
|
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
|
||||||
@@ -490,6 +553,21 @@ app
|
|||||||
control.on('unmaximize', scheduleSave)
|
control.on('unmaximize', scheduleSave)
|
||||||
control.on('close', saveWindowSettings)
|
control.on('close', saveWindowSettings)
|
||||||
secure(control)
|
secure(control)
|
||||||
|
ipcMain.handle('select-audio-output', (event, id: unknown) => {
|
||||||
|
requireControl(event)
|
||||||
|
return audioOutput.select(id)
|
||||||
|
})
|
||||||
|
ipcMain.handle('prepare-audio-output', async (event) => {
|
||||||
|
const current = output
|
||||||
|
if (!current || event.sender !== current.webContents || event.senderFrame !== current.webContents.mainFrame)
|
||||||
|
throw new Error('Недоступно')
|
||||||
|
try {
|
||||||
|
await audioOutput.prepare((id) => setAudioSink(current, id))
|
||||||
|
} catch (error) {
|
||||||
|
if (output === current) closeOutput('Не удалось выбрать устройство звука')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
ipcMain.handle('state', (event) => {
|
ipcMain.handle('state', (event) => {
|
||||||
if (
|
if (
|
||||||
![control?.webContents, output?.webContents].includes(event.sender) ||
|
![control?.webContents, output?.webContents].includes(event.sender) ||
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('screenBridge', {
|
contextBridge.exposeInMainWorld('screenBridge', {
|
||||||
|
selectAudioOutput: (id: string) => ipcRenderer.invoke('select-audio-output', id),
|
||||||
|
prepareAudioOutput: () => ipcRenderer.invoke('prepare-audio-output'),
|
||||||
planStatus: () => ipcRenderer.invoke('plan-status'),
|
planStatus: () => ipcRenderer.invoke('plan-status'),
|
||||||
onlineState: () => ipcRenderer.invoke('online-state'),
|
onlineState: () => ipcRenderer.invoke('online-state'),
|
||||||
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
|
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
|
||||||
@@ -23,3 +25,9 @@ contextBridge.exposeInMainWorld('screenBridge', {
|
|||||||
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
|
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
|
||||||
openMaterialsFolder: (key: string) => ipcRenderer.invoke('open-materials-folder', key),
|
openMaterialsFolder: (key: string) => ipcRenderer.invoke('open-materials-folder', key),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (new URLSearchParams(location.search).has('audio-devices')) {
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', () => {
|
||||||
|
ipcRenderer.send('audio-devices-changed')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
|
|||||||
import type { Rectangle } from 'electron'
|
import type { Rectangle } from 'electron'
|
||||||
|
|
||||||
export interface WindowPreferences {
|
export interface WindowPreferences {
|
||||||
|
audioOutput?: string
|
||||||
control: { bounds: Rectangle; maximized: boolean } | null
|
control: { bounds: Rectangle; maximized: boolean } | null
|
||||||
display: { id: number; label: string } | null
|
display: { id: number; label: string } | null
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,11 @@ export function parseWindowPreferences(value: unknown): WindowPreferences {
|
|||||||
const data = object(value)
|
const data = object(value)
|
||||||
if (data.version !== 1) throw new Error('Неизвестная версия настроек окон')
|
if (data.version !== 1) throw new Error('Неизвестная версия настроек окон')
|
||||||
const result = defaults()
|
const result = defaults()
|
||||||
|
if (data.audioOutput !== undefined) {
|
||||||
|
if (typeof data.audioOutput !== 'string' || data.audioOutput.length > 1024)
|
||||||
|
throw new Error('Некорректное устройство звука')
|
||||||
|
result.audioOutput = data.audioOutput
|
||||||
|
}
|
||||||
if (data.control !== null) {
|
if (data.control !== null) {
|
||||||
const c = object(data.control)
|
const c = object(data.control)
|
||||||
const b = object(c.bounds)
|
const b = object(c.bounds)
|
||||||
|
|||||||
+16
-3
@@ -243,12 +243,12 @@ app.whenReady().then(async () => {
|
|||||||
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
||||||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||||
let output = await poll(
|
let output = await poll(
|
||||||
() => BrowserWindow.getAllWindows().find((w) => w !== control),
|
() => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'),
|
||||||
'output',
|
'output',
|
||||||
)
|
)
|
||||||
const inspect = (source) =>
|
const inspect = (source) =>
|
||||||
BrowserWindow.getAllWindows()
|
BrowserWindow.getAllWindows()
|
||||||
.find((w) => w !== control)
|
.find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1')
|
||||||
.webContents.executeJavaScript(source)
|
.webContents.executeJavaScript(source)
|
||||||
await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared')
|
await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared')
|
||||||
if (process.platform === 'linux')
|
if (process.platform === 'linux')
|
||||||
@@ -265,6 +265,19 @@ app.whenReady().then(async () => {
|
|||||||
await checkDisplay(output)
|
await checkDisplay(output)
|
||||||
await command({ action: 'play', channel: 'main' })
|
await command({ action: 'play', channel: 'main' })
|
||||||
await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play')
|
await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play')
|
||||||
|
const audioHost = await evaluate('window.screenBridge.host()')
|
||||||
|
assert.equal(await evaluate('!!document.querySelector("#audio-output")'), true)
|
||||||
|
assert.equal(await evaluate('window.screenBridge.selectAudioOutput("missing").then(() => false, () => true)'), true)
|
||||||
|
if (audioHost.audioOutputs.length) {
|
||||||
|
const device = audioHost.audioOutputs[0].id
|
||||||
|
const before = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime')
|
||||||
|
await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(device)})`)
|
||||||
|
assert.deepEqual(await inspect('Array.from(document.querySelectorAll("audio,video")).map(media => media.sinkId)'), [device, device, device, device])
|
||||||
|
assert.equal(await inspect('document.querySelector("audio[data-channel=main-audio]").paused'), false)
|
||||||
|
assert.ok(await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') >= before)
|
||||||
|
await evaluate('window.screenBridge.selectAudioOutput("")')
|
||||||
|
assert.equal((await evaluate('window.screenBridge.host()')).audioOutputId, '')
|
||||||
|
}
|
||||||
await command({ action: 'pause', channel: 'main' })
|
await command({ action: 'pause', channel: 'main' })
|
||||||
await command({ action: 'resume', channel: 'main' })
|
await command({ action: 'resume', channel: 'main' })
|
||||||
await command({ action: 'stop', channel: 'main' })
|
await command({ action: 'stop', channel: 'main' })
|
||||||
@@ -599,7 +612,7 @@ app.whenReady().then(async () => {
|
|||||||
const oldSession = (await state()).session
|
const oldSession = (await state()).session
|
||||||
output.close()
|
output.close()
|
||||||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||||
output = await poll(() => BrowserWindow.getAllWindows().find((w) => w !== control), 'reopened')
|
output = await poll(() => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'reopened')
|
||||||
await poll(async () => {
|
await poll(async () => {
|
||||||
const s = await state()
|
const s = await state()
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-1
@@ -5,10 +5,11 @@ import OutputPage from './components/OutputPage.vue'
|
|||||||
|
|
||||||
const hasBridge = !!window.screenBridge
|
const hasBridge = !!window.screenBridge
|
||||||
const isOutput = new URLSearchParams(location.search).has('output')
|
const isOutput = new URLSearchParams(location.search).has('output')
|
||||||
|
const isAudioDevices = new URLSearchParams(location.search).has('audio-devices')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-config-provider :theme="darkTheme">
|
<n-config-provider v-if="!isAudioDevices" :theme="darkTheme">
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<OutputPage v-if="isOutput && hasBridge" />
|
<OutputPage v-if="isOutput && hasBridge" />
|
||||||
<n-dialog-provider v-else>
|
<n-dialog-provider v-else>
|
||||||
|
|||||||
@@ -205,6 +205,12 @@ async function refreshHost() {
|
|||||||
if (display.value === null) display.value = host.value.preferredDisplayId
|
if (display.value === null) display.value = host.value.preferredDisplayId
|
||||||
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
|
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
|
||||||
}
|
}
|
||||||
|
async function selectAudioOutput(id: string) {
|
||||||
|
await run(async () => {
|
||||||
|
await bridge!.selectAudioOutput(id)
|
||||||
|
await refreshHost()
|
||||||
|
})
|
||||||
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||||
@@ -268,6 +274,19 @@ onUnmounted(() => {
|
|||||||
placeholder="Выберите внешний дисплей"
|
placeholder="Выберите внешний дисплей"
|
||||||
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
:input-props="{ 'aria-label': 'Дисплей показа' }"
|
||||||
/>
|
/>
|
||||||
|
<label for="audio-output-input">Вывод звука</label>
|
||||||
|
<n-select
|
||||||
|
id="audio-output"
|
||||||
|
:value="host.audioOutputId ?? ''"
|
||||||
|
:options="[
|
||||||
|
{ label: 'По умолчанию в системе', value: '' },
|
||||||
|
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
|
||||||
|
]"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
|
||||||
|
@update:value="selectAudioOutput"
|
||||||
|
/>
|
||||||
|
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
|
||||||
<n-space align="center">
|
<n-space align="center">
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!state?.outputOpen"
|
v-if="!state?.outputOpen"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const imageError = ref(false)
|
|||||||
const timerDisplay = useScreenTimer(() => state.value)
|
const timerDisplay = useScreenTimer(() => state.value)
|
||||||
const bridge = window.screenBridge!
|
const bridge = window.screenBridge!
|
||||||
let player: MediaPlayer | undefined
|
let player: MediaPlayer | undefined
|
||||||
|
let disposed = false
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
let revision = -1
|
let revision = -1
|
||||||
let sceneToken = ''
|
let sceneToken = ''
|
||||||
@@ -86,6 +87,7 @@ async function prepareScene(snapshot: State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function apply(value: State) {
|
function apply(value: State) {
|
||||||
|
if (disposed) return
|
||||||
if (value.revision <= revision) return
|
if (value.revision <= revision) return
|
||||||
revision = value.revision
|
revision = value.revision
|
||||||
state.value = value
|
state.value = value
|
||||||
@@ -97,6 +99,12 @@ function apply(value: State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await bridge.prepareAudioOutput()
|
||||||
|
} catch {
|
||||||
|
return // Ошибка устройства отображается в пульте; не запускать звук на другом выходе.
|
||||||
|
}
|
||||||
|
if (disposed) return
|
||||||
player = new MediaPlayer(
|
player = new MediaPlayer(
|
||||||
{ audio: audio.value!, video: video.value!, music: [musicA.value!, musicB.value!] },
|
{ audio: audio.value!, video: video.value!, music: [musicA.value!, musicB.value!] },
|
||||||
(report) => bridge.report(report),
|
(report) => bridge.report(report),
|
||||||
@@ -105,6 +113,7 @@ onMounted(async () => {
|
|||||||
apply(await bridge.state())
|
apply(await bridge.state())
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
sceneAbort?.abort()
|
sceneAbort?.abort()
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
player?.destroy()
|
player?.destroy()
|
||||||
|
|||||||
@@ -125,7 +125,14 @@ export interface DisplayInfo {
|
|||||||
id: number
|
id: number
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
export interface AudioOutputInfo {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
export interface HostInfo {
|
export interface HostInfo {
|
||||||
|
audioOutputs?: AudioOutputInfo[]
|
||||||
|
audioOutputId?: string
|
||||||
|
audioOutputNotice?: string
|
||||||
notice: string
|
notice: string
|
||||||
sleepBlocked: boolean
|
sleepBlocked: boolean
|
||||||
preferredDisplayId: number | null
|
preferredDisplayId: number | null
|
||||||
@@ -146,6 +153,8 @@ export interface PlanStatus {
|
|||||||
notice: string
|
notice: string
|
||||||
}
|
}
|
||||||
export interface ScreenBridge {
|
export interface ScreenBridge {
|
||||||
|
selectAudioOutput: (id: string) => Promise<void>
|
||||||
|
prepareAudioOutput: () => Promise<void>
|
||||||
planStatus: () => Promise<PlanStatus>
|
planStatus: () => Promise<PlanStatus>
|
||||||
onlineState: () => Promise<OnlineState>
|
onlineState: () => Promise<OnlineState>
|
||||||
onlineAction: (intent: OnlineIntent) => Promise<OnlineState>
|
onlineAction: (intent: OnlineIntent) => Promise<OnlineState>
|
||||||
|
|||||||
Reference in New Issue
Block a user