diff --git a/docs/assets/screenshots/quickshell-osd-synthetic-brightness.png b/docs/assets/screenshots/quickshell-osd-synthetic-brightness.png new file mode 100644 index 000000000..616d57b64 Binary files /dev/null and b/docs/assets/screenshots/quickshell-osd-synthetic-brightness.png differ diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 654b0d641..eb5c339de 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -27,6 +27,7 @@ PanelWindow { required property var applicationCoordinator required property bool authenticationActive required property var keepAwakeController + required property var osdService screen: modelData // INVARIANT: une seule surface de barre porte l'inhibiteur, même avec @@ -454,6 +455,7 @@ PanelWindow { adapter: controlCenter.adapter controlCenterOpen: controlCenter.visible audioBrightnessController: controlCenter + onAdjustedFromBar: kind => bar.osdService.barAction(kind, bar.screen) keepAwakeController: bar.keepAwakeController clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing onOpenPageRequested: page => { @@ -496,6 +498,27 @@ PanelWindow { barWindow: bar keepAwakeController: bar.keepAwakeController onThresholdApplied: rightStatusArea.refreshBatteryThreshold() + onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage) + onCurrentPageChanged: bar.osdService.setPanel(bar.screen, visible, currentPage) + } + Connections { + target: controlCenter + function snapshot() { + if (!bar.startupHost) return; + bar.osdService.sampleSink(controlCenter.volumeIdentity, + controlCenter.volumeAvailable, controlCenter.volumeRaw, + controlCenter.volumePercent, controlCenter.volumeMuted); + bar.osdService.sampleBrightness(controlCenter.brightnessIdentity, + controlCenter.brightnessAvailable, controlCenter.brightnessRaw, + controlCenter.brightnessPercent); + } + function onVolumeIdentityChanged() { snapshot(); } + function onVolumeAvailableChanged() { snapshot(); } + function onVolumeRawChanged() { snapshot(); } + function onVolumeMutedChanged() { snapshot(); } + function onBrightnessAvailableChanged() { snapshot(); } + function onBrightnessRawChanged() { snapshot(); } + Component.onCompleted: snapshot() } DateCenter { diff --git a/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml b/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml new file mode 100644 index 000000000..925bc5670 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml @@ -0,0 +1,30 @@ +import Quickshell +import Quickshell.Wayland +import "osd" +import "theme" + +// Données de démonstration uniquement : aucun backend audio ni rétroéclairage. +ShellRoot { + // Fond de démonstration neutre sous la carte translucide : la capture ne + // révèle aucun pixel du bureau ni d'une application réelle. + PanelWindow { + screen: Quickshell.screens[0] + anchors { left: true; bottom: true } + margins.left: Math.max(0, Math.round((screen.width - implicitWidth) / 2)) + margins.bottom: Math.min(40, Math.max(8, Math.round(screen.height / 20))) + implicitWidth: Math.min(320, Math.max(1, screen.width - 16)) + implicitHeight: 80 + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "labfy-osd-visual-backdrop" + mask: Region { width: 0; height: 0 } + color: Theme.surface0 + } + OsdWindow { + hostScreen: Quickshell.screens[0] + kind: "brightness" + percent: 42 + muted: false + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index de8c4273a..f09c2e79b 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -211,6 +211,10 @@ Control Center, avec un minimum sûr de 10 % et un maximum de 100 %. Ses écriture, y compris si le pilote arrondit. L'absence de sortie audio ou de rétroéclairage désactive l'icône concernée. +L'OSD des réglages est décrit dans [osd/README.md](osd/README.md). Il observe +ces mêmes valeurs confirmées, ainsi que la sourdine de la source audio par +défaut, sans modifier les commandes des touches matérielles. + Le libellé `RESIZE` en Catppuccin Peach apparaît à gauche des workspaces quand l'événement IPC Sway `mode` annonce `resize`. Un seul abonnement `I3IpcListener` partage cet état entre toutes les sorties. Une requête diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 857b57455..3fe33624a 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -27,8 +27,12 @@ PopupWindow { readonly property bool volumeAvailable: volumeSlider.sinkAudio !== null readonly property bool volumeMuted: volumeSlider.muted readonly property int volumePercent: volumeSlider.percent + readonly property var volumeIdentity: volumeSlider.sink + readonly property real volumeRaw: volumeSlider.sinkAudio ? volumeSlider.sinkAudio.volume : NaN readonly property bool brightnessAvailable: brightnessSlider.available readonly property int brightnessPercent: brightnessSlider.percent + readonly property string brightnessIdentity: brightnessSlider.backlightPath + readonly property real brightnessRaw: brightnessSlider.brightness function adjustVolume(steps) { volumeSlider.adjustBy(steps); } function adjustBrightness(steps) { brightnessSlider.adjustBy(steps); } readonly property var pendingAction: sessionExitGate.pendingAction diff --git a/quickshell/.config/quickshell/labfy-sway/osd/Observation.js b/quickshell/.config/quickshell/labfy-sway/osd/Observation.js new file mode 100644 index 000000000..2607f67a7 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/Observation.js @@ -0,0 +1,36 @@ +.pragma library + +// CONTRACT: chaque identité de périphérique a une première valeur de référence, +// qui ne représente jamais une action. Les valeurs brutes détectent aussi les +// changements arrondis vers le même pourcentage affiché. +function observe(previous, identity, available, raw, muted) { + if (!identity || !available || !Number.isFinite(raw)) + return { state: null, changed: false, volumeChanged: false, muteChanged: false }; + const state = { identity: identity, raw: raw, muted: !!muted }; + if (!previous || previous.identity !== identity) + return { state: state, changed: false, volumeChanged: false, muteChanged: false }; + const volumeChanged = previous.raw !== raw; + const muteChanged = previous.muted !== !!muted; + return { state: state, changed: volumeChanged || muteChanged, + volumeChanged: volumeChanged, muteChanged: muteChanged }; +} + +function nextCard(card, kind, value, muted, screen, now) { + return { kind: kind, value: value, muted: muted, screen: screen, deadline: now + 1500, + generation: card ? card.generation + 1 : 1 }; +} + +function expire(card, generation, now) { + return !!card && card.generation === generation && now >= card.deadline; +} + +function screenForEvent(screens, focusedName, origin, kind, now) { + if (origin && origin.kind === kind && origin.until >= now + && screens.indexOf(origin.screen) >= 0) return origin.screen; + return screens.find(screen => screen.name === focusedName) || screens[0] || null; +} + +function pageShows(kind, page) { + return kind === "brightness" ? page === 0 + : kind === "microphone" ? page === 15 : page === 0 || page === 15; +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml b/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml new file mode 100644 index 000000000..5b3a68ffb --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml @@ -0,0 +1,108 @@ +import QtQuick +import Quickshell +import Quickshell.I3 +import Quickshell.Services.Pipewire +import "Observation.js" as Observation + +Item { + id: service + required property bool authenticationActive + property var sinkState: null + property var brightnessState: null + property var sourceState: null + property var card: null + property var panels: ({}) + property var origin: null + readonly property var source: Pipewire.defaultAudioSource + readonly property var sourceAudio: source && source.ready && source.audio ? source.audio : null + + // WHY: AudioMixerPage ne suit les nœuds que pendant son ouverture. Ce + // tracker léger permet de lire la sourdine du défaut sans ouvrir de flux. + PwObjectTracker { objects: [service.source] } + onSourceAudioChanged: sampleSource() + Connections { + target: service.sourceAudio + function onMutedChanged() { service.sampleSource(); } + } + function sampleSource() { + const audio = sourceAudio; + const result = Observation.observe(sourceState, source, !!audio, + audio ? audio.volume : NaN, audio ? audio.muted : false); + sourceState = result.state; + if (result.muteChanged) publish("microphone", 0, audio.muted); + } + function sampleSink(identity, available, raw, percent, muted) { + const result = Observation.observe(sinkState, identity, available, raw, muted); + sinkState = result.state; + if (result.muteChanged || result.volumeChanged) + publish("volume", percent, muted); + } + function sampleBrightness(identity, available, raw, percent) { + const result = Observation.observe(brightnessState, identity, available, raw, false); + brightnessState = result.state; + if (result.changed) publish("brightness", percent, false); + } + function barAction(kind, screen) { + // L'indice de provenance n'est consommé que par une valeur confirmée ; + // il expire afin qu'une modification externe ultérieure suive le focus. + origin = { kind: kind, screen: screen, until: Date.now() + 700 }; + } + function setPanel(screen, visible, page) { + const next = Object.assign({}, panels); + if (screen) next[screen.name] = visible ? page : -1; + panels = next; + } + function panelShows(kind) { + // Tous les Control Centers montrent les mêmes réglages globaux ; une + // page visible sur une autre sortie suffit donc à rendre l'OSD redondant. + return Object.keys(panels).some(name => Observation.pageShows(kind, panels[name])); + } + function focusedScreen() { + const monitor = I3.focusedMonitor; + const screens = Quickshell.screens; + return Observation.screenForEvent(screens, monitor ? monitor.name : "", + null, "", Date.now()); + } + function publish(kind, percent, muted) { + if (authenticationActive) return; + const now = Date.now(); + const monitor = I3.focusedMonitor; + const screen = Observation.screenForEvent(Quickshell.screens, + monitor ? monitor.name : "", origin, kind, now); + if (origin && (origin.kind === kind || origin.until < now)) origin = null; + if (!screen || panelShows(kind)) return; + // INVARIANT: une seule carte et une seule échéance courante ; un ancien + // callback ne peut fermer une génération plus récente. + card = Observation.nextCard(card, kind, percent, muted, screen, now); + expiry.restart(); + } + onAuthenticationActiveChanged: if (authenticationActive) { + card = null; + origin = null; + expiry.stop(); + } + Timer { + id: expiry + interval: 1500 + repeat: false + property int generation: 0 + onTriggered: { + if (Observation.expire(service.card, generation, Date.now())) service.card = null; + } + function restart() { + generation = service.card.generation; + stop(); + start(); + } + } + LazyLoader { + active: !!service.card && !service.authenticationActive + OsdWindow { + // LazyLoader peut garder l'objet pendant une frame de destruction. + hostScreen: service.card ? service.card.screen : service.focusedScreen() + kind: service.card ? service.card.kind : "volume" + percent: service.card ? service.card.value : 0 + muted: service.card ? service.card.muted : false + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml b/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml new file mode 100644 index 000000000..27c37f345 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml @@ -0,0 +1,93 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "../components" +import "../theme" + +PanelWindow { + id: window + required property var hostScreen + required property string kind + required property int percent + required property bool muted + screen: hostScreen + anchors { left: true; bottom: true } + margins.left: Math.max(0, Math.round((hostScreen.width - implicitWidth) / 2)) + margins.bottom: Math.min(40, Math.max(8, Math.round(hostScreen.height / 20))) + implicitWidth: Math.min(320, Math.max(1, hostScreen.width - 16)) + implicitHeight: 80 + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + aboveWindows: true + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "labfy-setting-osd" + color: "transparent" + // CONTRACT: Region vide au niveau de la fenêtre Wayland : ni clic, ni + // défilement, ni glisser-déposer ne sont dirigés vers la carte. + mask: Region { width: 0; height: 0 } + + readonly property color emphasis: kind === "brightness" ? Theme.yellow + : muted ? Theme.danger : Theme.mauve + readonly property string label: kind === "brightness" ? "Luminosité" + : kind === "microphone" ? "Microphone" : "Volume" + readonly property string glyph: kind === "brightness" ? "󰖨" + : kind === "microphone" ? (muted ? "󰍭" : "󰍬") + : (muted ? "󰝟" : "󰕾") + + Rectangle { + anchors.fill: parent + radius: 4 + // WHY: un léger aperçu du contenu reste visible sans changer de palette. + color: Qt.rgba(Theme.popupBackground.r, Theme.popupBackground.g, + Theme.popupBackground.b, 0.92) + border.color: Theme.outline + border.width: 1 + NerdIcon { + id: icon + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.top: parent.top + anchors.topMargin: 13 + text: window.glyph + color: window.emphasis + font.pixelSize: 26 + } + Text { + anchors.left: icon.right + anchors.leftMargin: 12 + anchors.verticalCenter: icon.verticalCenter + text: window.label + color: Theme.foreground + font.pixelSize: 15 + font.bold: true + } + Text { + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: icon.verticalCenter + text: window.kind === "microphone" ? (window.muted ? "Muet" : "Non muet") + : window.muted ? "Muet · " + window.percent + "%" : window.percent + "%" + color: window.muted ? Theme.danger : Theme.foreground + font.pixelSize: 14 + } + Rectangle { + visible: window.kind !== "microphone" + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottom: parent.bottom + anchors.bottomMargin: 15 + height: 7 + radius: 3 + color: Theme.buttonBackground + Rectangle { + width: Math.max(0, Math.min(1, window.percent / 100)) * parent.width + height: parent.height + radius: parent.radius + color: window.emphasis + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/README.md b/quickshell/.config/quickshell/labfy-sway/osd/README.md new file mode 100644 index 000000000..c17f24868 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/README.md @@ -0,0 +1,43 @@ +# OSD volume, luminosité et microphone + +`OsdService` observe le sink PipeWire par défaut via le `VolumeSlider` déjà +partagé entre la barre et le Control Center. Il observe le rétroéclairage via +le `BrightnessSlider` existant, et suit en plus le nœud source PipeWire par +défaut avec `PwObjectTracker`, même si la page Audio n'a jamais été ouverte. +Ce dernier suivi ne crée aucun flux de capture. Les touches Sway gardent leurs +commandes `pactl` et `brightnessctl`, leurs pas et leurs bornes. Le défilement +de la barre utilise toujours les mêmes curseurs. Un changement externe des +mêmes valeurs est également détecté. Les flux d'applications du mixeur ne +déclenchent rien. + +Chaque nouvelle identité de périphérique et chaque reconnexion établissent +une référence sans affichage. Une valeur brute identique est ignorée ; la +carte présente la valeur observée après confirmation, y compris un volume +externe supérieur à 100 %. Seule la jauge est plafonnée. Une valeur +indisponible ne devient pas `0 %`. Le microphone indique `Muet` ou `Non muet`, +sans prétendre détecter une capture active. Lorsqu'un contrôle correspondant +est visible dans un Control Center, l'OSD est supprimé. Toute carte active est +fermée dès le début d'une authentification Polkit, sans rejeu ultérieur. + +La surface `labfy-setting-osd` est une seule `PanelWindow` de 320 × 80 pixels +logiques au maximum, centrée en bas à environ 40 pixels de la sortie choisie. +Son fond reprend `Theme.popupBackground` à 92 % d'opacité. +Une action de barre privilégie sa sortie ; les autres changements suivent le +moniteur focalisé de `I3`, avec repli sur la première sortie disponible. +Elle est sur la couche `Overlay`, au-dessus d'un contenu plein écran, avec +`WlrKeyboardFocus.None`, zone d'exclusion ignorée et région d'entrée Wayland +vide. Elle ne prend donc ni focus ni entrée pointeur. Sa création n'ouvre ni +panneau, ni notification, ni `IdleInhibitor` ; la tasse et le pont Firefox ne +sont pas liés à cette surface. + +Chaque événement pertinent remplace le contenu de la carte et relance son +délai de 1500 ms. Une génération de minuteur empêche une ancienne échéance +de fermer une carte plus récente. La surface est détruite à l'expiration. +L'Overview évite de capturer cette surface sans supprimer ses miniatures +existantes. + +`node tests/quickshell/test_osd_observation.js` vérifie les références, les +changements, les rafales, les échéances, la sourdine synthétique et le choix de +sortie. `OsdVisualFixture.qml` montre une luminosité fictive de 42 % ; sa +[capture recadrée](../../../../../docs/assets/screenshots/quickshell-osd-synthetic-brightness.png) +ne contient aucune donnée personnelle. diff --git a/quickshell/.config/quickshell/labfy-sway/overview/README.md b/quickshell/.config/quickshell/labfy-sway/overview/README.md index dae36920e..2b8382924 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/README.md +++ b/quickshell/.config/quickshell/labfy-sway/overview/README.md @@ -9,6 +9,9 @@ utiles sur `eDP-1`, sans défilement. Sur les petits écrans, le défilement res disponible si la grille dépasse réellement la hauteur utile. La surface layer-shell `Overlay` prend le focus clavier exclusivement pendant l'ouverture et libère ce focus en se fermant. +La surface `labfy-setting-osd` suspend seulement une nouvelle capture +automatique tant qu'elle est visible ; les miniatures déjà validées restent +disponibles. ## Provenance des miniatures diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 91b6d161a..259075010 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -173,7 +173,8 @@ def state(): output_info = {item["name"]: item for item in outputs if item.get("active")} overlay_outputs = [item["name"] for item in outputs if item.get("active") and any(surface.get("namespace") in { - "labfy-workspace-overview", "labfy-applications-menu"} + "labfy-workspace-overview", "labfy-applications-menu", + "labfy-setting-osd"} for surface in item.get("layer_shell_surfaces") or [])] workspace_nodes = {} diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 59f374de2..6c1887b69 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -8,12 +8,14 @@ import "notifications" import "idlebridge" import "theme" import "polkit" +import "osd" ShellRoot { id: shell // CONTRACT: one PolkitAgent owns the native request queue for the entire // user session. A dialog is constructed only for its current flow. PolkitAgent { id: polkitAgent } + OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive } property var polkitScreen: null readonly property bool authenticationActive: polkitAgent.flow !== null signal authenticationOpening() @@ -200,6 +202,7 @@ ShellRoot { model: Quickshell.screens Bar { + osdService: shellOsdService applicationCoordinator: shell authenticationActive: shell.authenticationActive keepAwakeController: shell diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index ac342a83a..b508cbcaf 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -16,6 +16,7 @@ Row { signal toggleControlCenterRequested() signal toggleRemovableMediaRequested() signal toggleClipboardRequested() + signal adjustedFromBar(string kind) function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() } function closeRemovableMedia() { removableMediaIndicator.popupOpen = false } function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open } @@ -42,7 +43,10 @@ Row { available: area.audioBrightnessController.volumeAvailable clickable: true onActivated: area.openPageRequested(15) - onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps) + onAdjusted: steps => { + area.adjustedFromBar("volume"); + area.audioBrightnessController.adjustVolume(steps); + } } ScrollIcon { glyph: "󰖨" @@ -51,7 +55,10 @@ Row { label: "Luminosité" percent: area.audioBrightnessController.brightnessPercent available: area.audioBrightnessController.brightnessAvailable - onAdjusted: steps => area.audioBrightnessController.adjustBrightness(steps) + onAdjusted: steps => { + area.adjustedFromBar("brightness"); + area.audioBrightnessController.adjustBrightness(steps); + } } } Rectangle { diff --git a/tests/quickshell/test_osd_observation.js b/tests/quickshell/test_osd_observation.js new file mode 100644 index 000000000..9391bc34e --- /dev/null +++ b/tests/quickshell/test_osd_observation.js @@ -0,0 +1,54 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const source = fs.readFileSync(path.resolve(__dirname, + "../../quickshell/.config/quickshell/labfy-sway/osd/Observation.js"), "utf8") + .replace(/^\.pragma library\s*/, ""); +const model = {}; +vm.createContext(model); +vm.runInContext(source, model); + +const sink = {}; +let result = model.observe(null, sink, true, 0.57, false); +assert.equal(result.changed, false); // Première lecture : référence seulement. +result = model.observe(result.state, sink, true, 0.57, false); +assert.equal(result.changed, false); +result = model.observe(result.state, sink, true, 0.571, false); +assert.equal(result.volumeChanged, true); // Même pourcentage arrondi, autre valeur brute. +result = model.observe(result.state, sink, true, 0.571, true); +assert.equal(result.muteChanged, true); +let microphone = model.observe(null, "source A", true, 0.36, false); +assert.equal(microphone.changed, false); // Page Audio jamais ouverte. +microphone = model.observe(microphone.state, "source A", true, 0.36, true); +assert.equal(microphone.muteChanged, true); +microphone = model.observe(microphone.state, "source B", true, 0.2, false); +assert.equal(microphone.changed, false); +result = model.observe(result.state, {}, true, 0.25, false); +assert.equal(result.changed, false); // Nouveau défaut : état initial ignoré. +result = model.observe(result.state, null, false, NaN, false); +assert.equal(result.state, null); +result = model.observe(result.state, sink, true, 0.57, false); +assert.equal(result.changed, false); // Reconnexion : nouvelle référence. + +const screen = { name: "sortie-test" }; +let card = model.nextCard(null, "volume", 57, false, screen, 0); +card = model.nextCard(card, "volume", 58, false, screen, 500); +assert.equal(card.deadline, 2000); +assert.equal(model.expire(card, 1, 1500), false); +card = model.nextCard(card, "brightness", 42, false, screen, 800); +assert.equal(card.kind, "brightness"); +assert.equal(model.expire(card, 2, 2000), false); // Ancien timer. +assert.equal(model.expire(card, 3, 2299), false); +assert.equal(model.expire(card, 3, 2300), true); +const other = { name: "autre-sortie" }; +assert.equal(model.screenForEvent([screen, other], other.name, null, "volume", 0), other); +assert.equal(model.screenForEvent([screen, other], other.name, + { kind: "volume", screen, until: 100 }, "volume", 50), screen); +assert.equal(model.screenForEvent([screen, other], other.name, + { kind: "volume", screen, until: 100 }, "volume", 101), other); +assert.equal(model.pageShows("brightness", 0), true); +assert.equal(model.pageShows("microphone", 0), false); +assert.equal(model.pageShows("microphone", 15), true); +console.log("OSD observation et minuteur: PASS");