diff --git a/desktop-client/README.md b/desktop-client/README.md index d83f10e..d5c2462 100644 --- a/desktop-client/README.md +++ b/desktop-client/README.md @@ -36,7 +36,8 @@ stay in chat history; joining or reconnecting does not replay animations. Quiet sound effects play automatically with visible reactions. Sounds are original synthesized effects cached locally, with no external downloads. Overlays and sound are -suppressed while the player is hidden, minimized, or another application is active. +suppressed while the player is hidden or minimized. Visible players receive +reactions even without keyboard focus, without activating the player window. Reactions also work while the detached chat window is focused. Robbery votes allow one answer per person, which you can change before the vote diff --git a/desktop-client/fight_reactions.py b/desktop-client/fight_reactions.py index 897c882..e01c7c9 100644 --- a/desktop-client/fight_reactions.py +++ b/desktop-client/fight_reactions.py @@ -6,7 +6,7 @@ import wave from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform -from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout +from PyQt6.QtWidgets import QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout REACTIONS = { @@ -157,11 +157,7 @@ class RingsideStrip(QWidget): if kind not in REACTIONS or not self.video_frame.isVisible(): return False owner = self.video_frame.window() - # Avoid displaying an always-on-top effect over another application. - active = QApplication.activeWindow() - while active is not None and active is not owner: - active = active.parentWidget() - if active is None or owner.isMinimized(): + if owner.isMinimized(): return False if self.owner_window is not owner: if self.owner_window is not None: @@ -211,7 +207,9 @@ class RingsideStrip(QWidget): def eventFilter(self, watched, event): if self.isVisible(): - if event.type() in (QEvent.Type.Hide, QEvent.Type.WindowDeactivate, QEvent.Type.Close): + if event.type() in (QEvent.Type.Hide, QEvent.Type.Close): + self.dismiss() + elif event.type() == QEvent.Type.WindowStateChange and self.owner_window.isMinimized(): self.dismiss() elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize): self.slide.stop() diff --git a/desktop-client/test_fight_reactions.py b/desktop-client/test_fight_reactions.py index bab914e..9c17d59 100644 --- a/desktop-client/test_fight_reactions.py +++ b/desktop-client/test_fight_reactions.py @@ -44,7 +44,7 @@ def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind): qtbot.wait(50) bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size()) assert bounds.contains(strip.geometry()) - QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate)) + QApplication.sendEvent(room, QEvent(QEvent.Type.Hide)) assert not strip.isVisible() @@ -93,7 +93,7 @@ def test_arcade_ko_covers_video_then_restores_strip(room, qtbot): assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped -def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, qtbot, monkeypatch): +def test_sound_is_enabled_by_default_and_hidden_effects_are_suppressed(room, qtbot, monkeypatch): play = Mock() monkeypatch.setattr(room, '_play_reaction_sound', play) room.activateWindow() @@ -109,6 +109,29 @@ def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, assert not room.ringside_strip.isVisible() +@pytest.mark.parametrize('kind', ['ko', 'bell']) +def test_receiving_client_shows_reaction_without_focus(room, qtbot, monkeypatch, kind): + from PyQt6.QtWidgets import QLineEdit + other_client = QLineEdit() + qtbot.addWidget(other_client) + other_client.show() + other_client.activateWindow() + other_client.setFocus() + qtbot.wait(30) + assert not room.isActiveWindow() + play = Mock() + monkeypatch.setattr(room, '_play_reaction_sound', play) + room.handle_fight_reaction({'kind': kind, 'username': 'Other client', 'position': 0}) + assert room.ringside_strip.isVisible() + play.assert_called_once_with(kind) + assert QApplication.focusWidget() is other_client + QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate)) + assert room.ringside_strip.isVisible() + room.showMinimized() + qtbot.wait(30) + assert not room.ringside_strip.isVisible() + + @pytest.mark.parametrize('kind', REACTIONS) def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind): path = write_reaction_sound(tmp_path, kind)