Add desktop ready checks, smarter chat scrolling, and fight-night reactions
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Native ringside overlay and small, original synthesized reaction sounds."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import wave
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QPoint, QEasingCurve, QEvent
|
||||
from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
|
||||
|
||||
|
||||
REACTIONS = {
|
||||
"ko": ("K.O.", "That's a wrap.", "#ff5676"),
|
||||
"bell": ("DING!", "Back to your corners.", "#f5c86c"),
|
||||
"tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
|
||||
"robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
|
||||
}
|
||||
|
||||
|
||||
def write_reaction_sound(directory, kind):
|
||||
"""Generate mono PCM once per cache directory, with no downloaded samples."""
|
||||
if kind not in REACTIONS:
|
||||
raise ValueError("Unknown reaction")
|
||||
path = Path(directory) / f"{kind}.wav"
|
||||
if path.exists():
|
||||
return path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rate = 22050
|
||||
duration = .85 if kind == "bell" else .4
|
||||
samples = bytearray()
|
||||
for i in range(int(rate * duration)):
|
||||
t = i / rate
|
||||
attack = min(1, t / .008)
|
||||
if kind == "ko":
|
||||
value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
|
||||
elif kind == "bell":
|
||||
value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
|
||||
((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
|
||||
elif kind == "tap":
|
||||
pulse = t if t < .16 else t - .16
|
||||
value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
|
||||
else:
|
||||
value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
|
||||
release = min(1, (duration - t) / .02)
|
||||
samples.extend(struct.pack("<h", int(16000 * value * attack * release)))
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(1)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(samples)
|
||||
return path
|
||||
|
||||
|
||||
class RingsideStrip(QWidget):
|
||||
"""A separate native window stays above VLC's native video surface."""
|
||||
def __init__(self, video_frame):
|
||||
super().__init__(video_frame, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.WindowTransparentForInput)
|
||||
self.video_frame = video_frame
|
||||
self.owner_window = None
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
self.panel = QFrame()
|
||||
outer.addWidget(self.panel)
|
||||
row = QHBoxLayout(self.panel)
|
||||
row.setContentsMargins(14, 10, 14, 10)
|
||||
row.setSpacing(14)
|
||||
self.headline = QLabel()
|
||||
self.headline.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.caption = QLabel()
|
||||
self.caption.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.caption.setWordWrap(True)
|
||||
self.caption.setMinimumWidth(0)
|
||||
self.caption.setStyleSheet("color: #f2f2f5; background: transparent; font-size: 13px;")
|
||||
self.metadata = QLabel()
|
||||
self.metadata.setStyleSheet("color: #a6a9b5; background: transparent; font-size: 11px;")
|
||||
details = QVBoxLayout()
|
||||
details.setSpacing(3)
|
||||
details.addWidget(self.caption)
|
||||
details.addWidget(self.metadata)
|
||||
row.addWidget(self.headline)
|
||||
row.addLayout(details, 1)
|
||||
self.dismiss_timer = QTimer(self)
|
||||
self.dismiss_timer.setSingleShot(True)
|
||||
self.dismiss_timer.timeout.connect(self.dismiss)
|
||||
self.slide = QPropertyAnimation(self, b"pos", self)
|
||||
self.slide.setDuration(180)
|
||||
self.slide.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
video_frame.installEventFilter(self)
|
||||
|
||||
def show_reaction(self, kind, author, position):
|
||||
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():
|
||||
return False
|
||||
if self.owner_window is not owner:
|
||||
if self.owner_window is not None:
|
||||
self.owner_window.removeEventFilter(self)
|
||||
self.owner_window = owner
|
||||
owner.installEventFilter(self)
|
||||
title, caption, color = REACTIONS[kind]
|
||||
self.headline.setText(title)
|
||||
self.headline.setStyleSheet(f"color: {color}; background: transparent; font-size: 24px; font-weight: bold;")
|
||||
self.caption.setText(f"{author}: {caption}")
|
||||
self.metadata.setText(f"Room reaction · {position}")
|
||||
self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}")
|
||||
self.slide.stop()
|
||||
self._place()
|
||||
end = self.pos()
|
||||
self.move(end + QPoint(0, 12))
|
||||
self.show()
|
||||
self.slide.setStartValue(self.pos())
|
||||
self.slide.setEndValue(end)
|
||||
self.slide.start()
|
||||
self.dismiss_timer.start(2200)
|
||||
return True
|
||||
|
||||
def _place(self):
|
||||
width = max(1, min(540, self.video_frame.width() - 32))
|
||||
self.setFixedWidth(width)
|
||||
height = self.layout().heightForWidth(width)
|
||||
self.setFixedHeight(max(64, height))
|
||||
origin = self.video_frame.mapToGlobal(QPoint(16, self.video_frame.height() - self.height() - 16))
|
||||
self.move(origin)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if self.isVisible():
|
||||
if event.type() in (QEvent.Type.Hide, QEvent.Type.WindowDeactivate, QEvent.Type.Close):
|
||||
self.dismiss()
|
||||
elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
|
||||
self.slide.stop()
|
||||
self._place()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def dismiss(self):
|
||||
self.slide.stop()
|
||||
self.dismiss_timer.stop()
|
||||
self.hide()
|
||||
Reference in New Issue
Block a user