From 37e0c73ab59487b4a17affbc05f9bb7a4552a444 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 21:11:04 +0200 Subject: [PATCH] Feat : Control streamer --- packages/agent/src/browser.ts | 112 ++++++++++++++++++ packages/agent/src/index.ts | 84 ++++++++++++- packages/agent/src/obs.ts | 8 +- packages/agent/src/watcher.ts | 5 + packages/server/src/agentGateway.ts | 1 + packages/server/src/api.ts | 29 ++++- packages/server/src/db.ts | 19 ++- packages/server/src/hub.ts | 5 +- packages/shared/src/index.ts | 55 +++++++++ packages/web/src/components/AgentSettings.tsx | 79 +++++++++++- 10 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 packages/agent/src/browser.ts diff --git a/packages/agent/src/browser.ts b/packages/agent/src/browser.ts new file mode 100644 index 0000000..5ca43c3 --- /dev/null +++ b/packages/agent/src/browser.ts @@ -0,0 +1,112 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { BrowserSettings } from '@stream-control/shared'; + +const run = promisify(execFile); + +export interface BrowserLog { + (level: 'info' | 'warn' | 'error', message: string): void; +} + +/** + * Seules les URL http(s) sont acceptées. Elles finissent en argument de + * processus — jamais dans un shell, donc pas d'injection possible — mais un + * `file://` ou un `javascript:` n'aurait rien à faire ici. + */ +function assertWebUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`URL invalide : ${url}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Protocole non autorisé : ${parsed.protocol}`); + } + return parsed.toString(); +} + +/** + * Ouvre une page dans le navigateur de la session graphique. + * + * On lance l'exécutable tel qu'il est installé, avec son profil par défaut : + * l'onglet hérite donc de la session déjà ouverte. Le processus est détaché — + * un navigateur déjà lancé délègue à l'instance existante et rend la main + * aussitôt, ce qui est le cas nominal. + */ +export async function openUrl( + settings: BrowserSettings, + url: string, + log: BrowserLog, +): Promise<{ command: string; url: string }> { + const target = assertWebUrl(url); + const args = [...settings.args, target]; + + log('info', `Ouverture de ${target} (${settings.command})`); + + const child = spawn(settings.command, args, { + detached: true, + stdio: 'ignore', + env: { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }, + }); + + return new Promise((resolve, reject) => { + child.once('error', (err: NodeJS.ErrnoException) => { + reject( + new Error( + err.code === 'ENOENT' + ? `Navigateur introuvable : « ${settings.command} ». Vérifie la commande configurée.` + : `Lancement du navigateur impossible : ${err.message}`, + ), + ); + }); + + // Pas d'erreur immédiate : le lancement est considéré comme parti. + setTimeout(() => { + child.unref(); + resolve({ command: settings.command, url: target }); + }, 400); + }); +} + +/** Ferme la fenêtre du lecteur, sans toucher au reste de la session. */ +export async function closeWindow( + windowMatch: string, + log: BrowserLog, +): Promise<{ closed: number }> { + const match = windowMatch.trim(); + if (!match) throw new Error('Aucun titre de fenêtre à cibler'); + + if (process.platform !== 'linux') { + throw new Error(`Fermeture de fenêtre non gérée sur ${process.platform}`); + } + + const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' }; + + let ids: string[] = []; + try { + const { stdout } = await run('xdotool', ['search', '--onlyvisible', '--name', match], { + env, + timeout: 5000, + }); + ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('xdotool est absent — apt install xdotool'); + } + return { closed: 0 }; // aucune fenêtre ne correspond + } + + for (const id of ids) { + // windowclose demande poliment la fermeture, contrairement à windowkill qui + // tuerait le client X entier — donc potentiellement tout le navigateur. + await run('xdotool', ['windowclose', id], { env, timeout: 5000 }).catch(() => undefined); + } + + log('info', `${ids.length} fenêtre(s) « ${match} » fermée(s)`); + return { closed: ids.length }; +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ca086d9..c703fdc 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -6,14 +6,17 @@ import type { AgentStatus, AgentToServer, LogLevel, + BrowserSettings, ServerToAgent, WatchSettings, } from '@stream-control/shared'; import { + DEFAULT_BROWSER_SETTINGS, DEFAULT_WATCH_SETTINGS, PROTOCOL_VERSION, detectPlatform, emptyStatus, + normalizeBrowserSettings, normalizeWatchSettings, safeJsonParse, } from '@stream-control/shared'; @@ -22,6 +25,8 @@ import { runDiagnostics } from './doctor.ts'; import { ObsController } from './obs.ts'; import { StreamWatcher } from './watcher.ts'; import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts'; +import { closeWindow, delay as sleep, openUrl } from './browser.ts'; +import { sendHotkey } from './hotkey.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; const AGENT_VERSION = '0.1.0'; @@ -50,6 +55,8 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, { }, }); +let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS; + let socket: WebSocket | null = null; let statusTimer: NodeJS.Timeout | null = null; let reconnectTimer: NodeJS.Timeout | null = null; @@ -149,6 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { obs.applySettings(message.obs); applyWatchSettings(message.watch); + applyBrowserSettings(message.browser); report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`); if (message.autoConnectObs && !obs.isConnected) { @@ -161,6 +169,7 @@ async function handleServerMessage(message: ServerToAgent): Promise { case 'config': { obs.applySettings(message.obs); applyWatchSettings(message.watch); + applyBrowserSettings(message.browser); if (message.autoConnectObs && !obs.isConnected) { obs.connect().catch((err: Error) => report('warn', err.message)); } @@ -187,9 +196,68 @@ async function handleServerMessage(message: ServerToAgent): Promise { } } +function requireUrl(params: Record): string { + const url = params.url; + if (typeof url !== 'string' || !url.trim()) throw new Error('Paramètre « url » manquant'); + return url.trim(); +} + /** - * Aiguille une action : la surveillance et le clavier sont gérés par l'agent, - * tout le reste part vers obs-websocket. + * Séquence complète : ouvrir la page, laisser le lecteur démarrer, passer en + * plein écran, lancer l'enregistrement. Chaque étape est journalisée séparément + * pour qu'un échec désigne son maillon. + */ +async function startCapture(params: Record): Promise { + if (!browserSettings.enabled) { + throw new Error('Pilotage du navigateur désactivé sur cet agent'); + } + + const url = requireUrl(params); + const opened = await openUrl(browserSettings, url, report); + + const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs); + report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`); + await sleep(wait); + + const fullscreen = watcher.snapshotSettings.fullscreen; + let fullscreenResult: unknown = 'ignoré'; + if (fullscreen.enabled) { + // Un échec ici ne doit pas empêcher l'enregistrement : mieux vaut capturer + // une fenêtre non maximisée que ne rien capturer du tout. + fullscreenResult = await sendHotkey({ + key: fullscreen.key, + windowMatch: fullscreen.windowMatch, + }).catch((err: Error) => { + report('warn', `Plein écran impossible : ${err.message}`); + return `échec : ${err.message}`; + }); + } + + await obs.execute('record.start'); + report('info', `Capture démarrée pour ${url}`); + + return { opened, fullscreen: fullscreenResult, recording: true }; +} + +async function stopCapture(): Promise { + const result = await obs.execute('record.stop'); + + let closed: unknown = 'conservée'; + if (browserSettings.enabled && browserSettings.closeOnStop) { + closed = await closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report).catch( + (err: Error) => { + report('warn', `Fermeture de la fenêtre impossible : ${err.message}`); + return `échec : ${err.message}`; + }, + ); + } + + return { ...(result as object), window: closed }; +} + +/** + * Aiguille une action : navigateur, surveillance et clavier sont gérés par + * l'agent lui-même ; tout le reste part vers obs-websocket. */ async function runAction(action: AgentAction, params: Record): Promise { switch (action) { @@ -197,6 +265,14 @@ async function runAction(action: AgentAction, params: Record): return watcher.checkNow(); case 'hotkey.fullscreen': return watcher.restoreFullscreen(); + case 'browser.open': + return openUrl(browserSettings, requireUrl(params), report); + case 'browser.close': + return closeWindow(watcher.snapshotSettings.fullscreen.windowMatch, report); + case 'capture.start': + return startCapture(params); + case 'capture.stop': + return stopCapture(); case 'agent.update': return selfUpdate( typeof params.url === 'string' && params.url ? params.url : config.packageUrl, @@ -214,6 +290,10 @@ function applyWatchSettings(raw: WatchSettings | undefined): void { watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS)); } +function applyBrowserSettings(raw: BrowserSettings | undefined): void { + browserSettings = normalizeBrowserSettings(raw ?? DEFAULT_BROWSER_SETTINGS); +} + // --- Boucle de statut -------------------------------------------------------- async function buildStatus(): Promise { diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index c5f8b77..c4cea58 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -8,7 +8,13 @@ import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shar */ export type ObsAction = Exclude< AgentAction, - 'watch.check' | 'hotkey.fullscreen' | 'agent.update' + | 'watch.check' + | 'hotkey.fullscreen' + | 'agent.update' + | 'browser.open' + | 'browser.close' + | 'capture.start' + | 'capture.stop' >; type ObsSnapshot = Pick< diff --git a/packages/agent/src/watcher.ts b/packages/agent/src/watcher.ts index 3f43cbc..c8342c1 100644 --- a/packages/agent/src/watcher.ts +++ b/packages/agent/src/watcher.ts @@ -55,6 +55,11 @@ export class StreamWatcher extends EventEmitter { return { ...this.state }; } + /** Réglages courants — la séquence de capture réutilise ceux du plein écran. */ + get snapshotSettings(): WatchSettings { + return this.settings; + } + applySettings(settings: WatchSettings): void { const restart = settings.enabled !== this.settings.enabled || diff --git a/packages/server/src/agentGateway.ts b/packages/server/src/agentGateway.ts index e29e861..70ad23a 100644 --- a/packages/server/src/agentGateway.ts +++ b/packages/server/src/agentGateway.ts @@ -111,6 +111,7 @@ export function handleAgentConnection( statusIntervalMs: config.statusIntervalMs, autoConnectObs: record.autoConnectObs, watch: record.watch, + browser: record.browser, }); break; } diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index d274301..40e0cd1 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -5,6 +5,7 @@ import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction, + normalizeBrowserSettings, normalizeWatchSettings, parseStripchatUsername, } from '@stream-control/shared'; @@ -89,6 +90,7 @@ api.patch('/agents/:id', (req, res) => { autoConnectObs: typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs, watch: normalizeWatchSettings(req.body?.watch ?? record.watch), + browser: normalizeBrowserSettings(req.body?.browser ?? record.browser), notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes, }); @@ -144,7 +146,9 @@ api.post('/agents/:id/command', async (req, res) => { } try { - const data = await hub.sendCommand(record.id, action, req.body?.params); + const timeoutMs = + action === 'capture.start' ? record.browser.readyDelayMs + 30_000 : undefined; + const data = await hub.sendCommand(record.id, action, req.body?.params, timeoutMs); hub.log(record.id, 'info', `Commande « ${action} » exécutée`); res.json({ ok: true, data }); } catch (err) { @@ -303,6 +307,7 @@ api.post('/watchlist/:id/record', async (req, res) => { obs: record.obs, autoConnectObs: record.autoConnectObs, watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }), + browser: record.browser, notes: record.notes, }); @@ -313,7 +318,21 @@ api.post('/watchlist/:id/record', async (req, res) => { } try { - await hub.sendCommand(record.id, 'record.start'); + // Avec le pilotage du navigateur, un seul appel enchaîne ouverture de la + // page, plein écran et enregistrement. Sinon on se contente de lancer OBS, + // en supposant la page déjà ouverte par l'opérateur. + if (record.browser.enabled) { + // La séquence attend le chargement de la page : le délai d'attente doit + // dépasser readyDelayMs, sinon la commande expire avant d'avoir abouti. + await hub.sendCommand( + record.id, + 'capture.start', + { url: target.url }, + record.browser.readyDelayMs + 30_000, + ); + } else { + await hub.sendCommand(record.id, 'record.start'); + } hub.log( record.id, 'info', @@ -334,7 +353,11 @@ api.post('/watchlist/:id/stop', async (req, res) => { return; } try { - await hub.sendCommand(target.agentId, 'record.stop'); + const agent = agentsRepo.get(target.agentId); + await hub.sendCommand( + target.agentId, + agent?.browser.enabled ? 'capture.stop' : 'record.stop', + ); res.json({ ok: true }); } catch (err) { res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index a6a3985..7559a9f 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -7,12 +7,15 @@ import type { ObsSettings, Platform, StreamState, + BrowserSettings, WatchSettings, WatchTarget, } from '@stream-control/shared'; import { + DEFAULT_BROWSER_SETTINGS, DEFAULT_OBS_SETTINGS, DEFAULT_WATCH_SETTINGS, + normalizeBrowserSettings, normalizeWatchSettings, safeJsonParse, stripchatProfileUrl, @@ -86,6 +89,7 @@ function addColumnIfMissing(table: string, column: string, definition: string): // Surveillance du stream source : stockée en JSON, le schéma évolue plus vite // que la table (nouveaux fournisseurs, nouveaux statuts). addColumnIfMissing('agents', 'watch_json', 'TEXT'); +addColumnIfMissing('agents', 'browser_json', 'TEXT'); // Enrichissement des profils surveillés : photo et historique de diffusion. addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT'); @@ -105,6 +109,7 @@ export interface AgentRow { obs_password: string; auto_connect: number; watch_json: string | null; + browser_json: string | null; notes: string | null; created_at: number; last_seen_at: number | null; @@ -120,6 +125,7 @@ export interface AgentRecord { obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; + browser: BrowserSettings; notes: string | null; createdAt: number; lastSeenAt: number | null; @@ -142,6 +148,9 @@ function toRecord(row: AgentRow): AgentRecord { watch: normalizeWatchSettings( row.watch_json ? safeJsonParse(row.watch_json) : DEFAULT_WATCH_SETTINGS, ), + browser: normalizeBrowserSettings( + row.browser_json ? safeJsonParse(row.browser_json) : DEFAULT_BROWSER_SETTINGS, + ), notes: row.notes, createdAt: Number(row.created_at), lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at), @@ -155,8 +164,8 @@ const stmts = { insertAgent: db.prepare(` INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash, obs_host, obs_port, obs_password, auto_connect, watch_json, - notes, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + browser_json, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), updateIdentity: db.prepare(` UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ? @@ -164,7 +173,7 @@ const stmts = { `), updateSettings: db.prepare(` UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?, - auto_connect = ?, watch_json = ?, notes = ? + auto_connect = ?, watch_json = ?, browser_json = ?, notes = ? WHERE id = ? `), touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'), @@ -207,6 +216,7 @@ export const agentsRepo = { obs?: Partial; autoConnectObs?: boolean; watch?: WatchSettings; + browser?: BrowserSettings; notes?: string | null; }): AgentRecord { const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs }; @@ -222,6 +232,7 @@ export const agentsRepo = { obs.password, input.autoConnectObs === false ? 0 : 1, JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)), + JSON.stringify(normalizeBrowserSettings(input.browser ?? DEFAULT_BROWSER_SETTINGS)), input.notes ?? null, Date.now(), ); @@ -250,6 +261,7 @@ export const agentsRepo = { obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; + browser: BrowserSettings; notes: string | null; }, ): void { @@ -260,6 +272,7 @@ export const agentsRepo = { settings.obs.password, settings.autoConnectObs ? 1 : 0, JSON.stringify(normalizeWatchSettings(settings.watch)), + JSON.stringify(normalizeBrowserSettings(settings.browser)), settings.notes, id, ); diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index 5fbed5e..a7cd1c1 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -103,6 +103,7 @@ class Hub { agentId: string, action: AgentAction, params?: Record, + timeoutMs = config.commandTimeoutMs, ): Promise { const connection = this.connections.get(agentId); if (!connection) throw new Error('Agent hors-ligne'); @@ -114,7 +115,7 @@ class Hub { const timer = setTimeout(() => { connection.pending.delete(requestId); reject(new Error(`Timeout : l'agent n'a pas répondu à « ${action} »`)); - }, config.commandTimeoutMs); + }, timeoutMs); connection.pending.set(requestId, { resolve, reject, timer }); @@ -137,6 +138,7 @@ class Hub { obs: record.obs, autoConnectObs: record.autoConnectObs, watch: record.watch, + browser: record.browser, }; connection.socket.send(JSON.stringify(message)); } @@ -174,6 +176,7 @@ class Hub { obs: { ...record.obs, password: record.obs.password ? '********' : '' }, autoConnectObs: record.autoConnectObs, watch: record.watch, + browser: record.browser, notes: record.notes, status: this.statusOf(record.id), }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1382602..0263331 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -45,6 +45,10 @@ export const AGENT_ACTIONS = [ 'recordDirectory.set', 'watch.check', 'hotkey.fullscreen', + 'browser.open', + 'browser.close', + 'capture.start', + 'capture.stop', 'agent.update', 'agent.ping', ] as const; @@ -106,6 +110,54 @@ export interface WatchSettings { fullscreen: FullscreenSettings; } +/** + * Pilotage du navigateur de la VM. + * + * On lance le navigateur déjà installé, avec son profil et sa session : c'est ce + * qui donne accès au flux comme si l'opérateur l'ouvrait lui-même. Aucune + * instance dédiée, aucun profil de test. + */ +export interface BrowserSettings { + enabled: boolean; + /** Exécutable du navigateur. */ + command: string; + /** Arguments placés avant l'URL. */ + args: string[]; + /** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */ + readyDelayMs: number; + /** Fermer la fenêtre quand l'enregistrement s'arrête. */ + closeOnStop: boolean; +} + +export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = { + enabled: false, + command: 'firefox', + args: ['--new-window'], + readyDelayMs: 8000, + closeOnStop: true, +}; + +export function normalizeBrowserSettings(raw: unknown): BrowserSettings { + const input = (raw ?? {}) as Partial; + const base = DEFAULT_BROWSER_SETTINGS; + const delay = Number(input.readyDelayMs); + + return { + enabled: input.enabled === true, + command: + typeof input.command === 'string' && input.command.trim() + ? input.command.trim() + : base.command, + args: Array.isArray(input.args) + ? input.args.filter((arg): arg is string => typeof arg === 'string' && arg.trim() !== '') + : base.args, + readyDelayMs: Number.isFinite(delay) + ? Math.min(Math.max(Math.round(delay), 0), 120_000) + : base.readyDelayMs, + closeOnStop: input.closeOnStop !== false, + }; +} + export const DEFAULT_WATCH_SETTINGS: WatchSettings = { enabled: false, provider: 'stripchat', @@ -288,6 +340,7 @@ export interface WelcomeMessage { /** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */ autoConnectObs: boolean; watch: WatchSettings; + browser: BrowserSettings; } export interface CommandMessage { @@ -302,6 +355,7 @@ export interface ConfigMessage { obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; + browser: BrowserSettings; } export interface PingMessage { @@ -327,6 +381,7 @@ export interface AgentView { obs: ObsSettings; autoConnectObs: boolean; watch: WatchSettings; + browser: BrowserSettings; notes: string | null; status: AgentStatus; } diff --git a/packages/web/src/components/AgentSettings.tsx b/packages/web/src/components/AgentSettings.tsx index 8681dbc..81c1af6 100644 --- a/packages/web/src/components/AgentSettings.tsx +++ b/packages/web/src/components/AgentSettings.tsx @@ -1,5 +1,10 @@ import { useState } from 'react'; -import type { AgentAction, AgentView, WatchSettings } from '@stream-control/shared'; +import type { + AgentAction, + AgentView, + BrowserSettings, + WatchSettings, +} from '@stream-control/shared'; import { api } from '../api'; interface Props { @@ -20,6 +25,11 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { const [token, setToken] = useState(null); const [busy, setBusy] = useState(false); const [watch, setWatch] = useState(agent.watch); + const [browser, setBrowser] = useState(agent.browser); + + function patchBrowser(patch: Partial) { + setBrowser((current) => ({ ...current, ...patch })); + } function patchWatch(patch: Partial) { setWatch((current) => ({ ...current, ...patch })); @@ -38,6 +48,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { autoConnectObs: autoConnect, obs: { host, port: Number(port), password }, watch, + browser, } as Partial); notify('Configuration enregistrée'); onClose(); @@ -139,6 +150,72 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) { +
+ Pilotage du navigateur + + + +
+ + +
+ +
+ +
+ + + +

+ La touche et le titre de fenêtre utilisés pour le plein écran sont ceux + configurés ci-dessous, dans « Surveillance du stream ». +

+
+
Surveillance du stream